178 lines
13 KiB
TypeScript
178 lines
13 KiB
TypeScript
import { createServer } from 'node:net'
|
|
import { randomUUID } from 'node:crypto'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import pg from 'pg'
|
|
import jwt from 'jsonwebtoken'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
|
|
import { runMigrations } from './migrations.js'
|
|
|
|
const { Client } = pg
|
|
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
|
const suite = baseDatabaseUrl ? describe : describe.skip
|
|
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
|
|
let adminClient: InstanceType<typeof Client>
|
|
let appServer: Awaited<typeof import('./index.js')>['server']
|
|
let appPool: Awaited<typeof import('./index.js')>['pool']
|
|
let baseUrl = ''
|
|
let adminAuthorization = ''
|
|
|
|
function adminFetch(url: string, init: RequestInit = {}) {
|
|
const headers = new Headers(init.headers)
|
|
headers.set('authorization', adminAuthorization)
|
|
return fetch(url, { ...init, headers })
|
|
}
|
|
|
|
async function availablePort() {
|
|
return new Promise<number>((resolve, reject) => {
|
|
const probe = createServer()
|
|
probe.once('error', reject)
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const address = probe.address()
|
|
const port = typeof address === 'object' && address ? address.port : 0
|
|
probe.close(error => error ? reject(error) : resolve(port))
|
|
})
|
|
})
|
|
}
|
|
|
|
const placed = (x: number, y: number, width: number, height: number, zIndex = 1) => ({ x, y, width, height, rotation: 0, zIndex, hidden: false })
|
|
|
|
suite('normalized level persistence API', () => {
|
|
beforeAll(async () => {
|
|
const adminUrl = new URL(baseDatabaseUrl!)
|
|
adminUrl.pathname = '/postgres'
|
|
adminClient = new Client({ connectionString: adminUrl.toString() })
|
|
await adminClient.connect()
|
|
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
|
const testUrl = new URL(baseDatabaseUrl!)
|
|
testUrl.pathname = `/${databaseName}`
|
|
const databaseUrl = testUrl.toString()
|
|
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
|
|
|
|
const port = await availablePort()
|
|
process.env.DATABASE_URL = databaseUrl
|
|
process.env.LEVEL_EDITING_ENABLED = 'true'
|
|
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
|
|
process.env.ASSET_STORAGE_DRIVER = 'memory'
|
|
process.env.PORT = String(port)
|
|
const serverModule = await import('./index.js')
|
|
appServer = serverModule.server
|
|
appPool = serverModule.pool
|
|
baseUrl = `http://127.0.0.1:${port}`
|
|
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
|
})
|
|
|
|
afterAll(async () => {
|
|
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
|
if (appPool) await appPool.end()
|
|
if (!adminClient) return
|
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
|
await adminClient.end()
|
|
})
|
|
|
|
it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
|
|
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
|
|
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
|
})
|
|
expect(createResponse.status).toBe(201)
|
|
const state = await createResponse.json() as CaseState
|
|
const timeline = state.views.find((view): view is TimelineView => view.type === 'timeline')!
|
|
timeline.rangeMode = 'fixed'
|
|
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
|
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
|
|
|
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
|
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
|
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
|
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
|
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
|
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
|
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
|
state.relations = [
|
|
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
|
|
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
|
|
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 },
|
|
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
|
|
{ id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
|
|
]
|
|
state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
|
|
state.brief = { body: 'Identify Ada Lovelace.', concepts: [{ id: randomUUID(), label: 'Ada Lovelace', context: 'Named in evidence.', expectedPartyKind: 'person', resolvedPartyExhibitId: party.id }] }
|
|
|
|
const save = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state) })
|
|
expect(await save.json()).toEqual({ ok: true, mode: 'author' })
|
|
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
|
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
|
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
|
|
|
const beforeFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
|
expect(beforeFlag.exhibits.map(item => item.id)).not.toContain(gatedDocument.id)
|
|
expect(beforeFlag.newlyVisibleDocumentIds).toContain(document.id)
|
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/flags`)).json()).toEqual([
|
|
{ key: 'tip.received', gatedDocumentCount: 1 },
|
|
])
|
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'PUT' })).status).toBe(200)
|
|
const afterFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
|
expect(afterFlag.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
|
expect(afterFlag.newlyVisibleDocumentIds).toContain(gatedDocument.id)
|
|
expect((await fetch(`${baseUrl}/api/levels/${state.id}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: afterFlag.newlyVisibleDocumentIds }) })).status).toBe(200)
|
|
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).newlyVisibleDocumentIds).toEqual([])
|
|
|
|
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'DELETE' })).status).toBe(200)
|
|
const matchRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
|
|
name: 'Smoke source passage', flagKey: 'tip.received', minimumAnchorMatches: 1,
|
|
anchors: [{ phrase: 'OSINT smoke evidence from the archive', minimumSimilarity: 0.72 }],
|
|
}),
|
|
})
|
|
expect(matchRuleResponse.status).toBe(201)
|
|
expect(await matchRuleResponse.json()).toMatchObject({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [{ phrase: 'OSINT smoke evidence from the archive' }] })
|
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`)).json()).toHaveLength(1)
|
|
|
|
const upload = new FormData()
|
|
upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
|
upload.append('x', '812')
|
|
upload.append('y', '438')
|
|
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: upload })
|
|
expect(uploadResponse.status).toBe(201)
|
|
const uploaded = await uploadResponse.json() as DocumentExhibit & { analysis: { extractionStatus: string; matchedFlags: string[]; awardedFlags: string[] } }
|
|
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text', x: 812, y: 438,
|
|
body: ['OSINT smoke evidence from the archlve'], analysis: { extractionStatus: 'succeeded', matchedFlags: ['tip.received'], awardedFlags: ['tip.received'] } })
|
|
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence from the archlve')
|
|
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
|
|
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
|
|
const automaticallyRevealed = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
|
expect(automaticallyRevealed.exhibits.map(item => item.id)).toContain(gatedDocument.id)
|
|
const evaluationRows = await appPool.query<{ matched: boolean; matched_anchor_count: number }>(
|
|
'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1', [uploaded.id])
|
|
expect(evaluationRows.rows).toEqual([{ matched: true, matched_anchor_count: 1 }])
|
|
|
|
const screenshot = new FormData()
|
|
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
|
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
|
expect(screenshotResponse.status).toBe(201)
|
|
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
|
|
|
|
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
|
expect(templateResponse.status).toBe(201)
|
|
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }) })
|
|
expect(cloneResponse.status).toBe(201)
|
|
const clone = await cloneResponse.json() as CaseState
|
|
expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
|
expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
|
|
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
|
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
|
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
|
expect.objectContaining({ name: 'Smoke source passage', flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
|
])
|
|
})
|
|
})
|