Files
kestrelos/test/integration/server-and-cot.spec.js
T
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
2026-06-24 20:54:50 +00:00

146 lines
4.9 KiB
JavaScript

/**
* @vitest-environment node
*
* Integration test: built server on 3000 (API) and 8089 (CoT). Uses temp DB and bootstrap
* user so CoT auth can be asserted (socket stays open on success).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { createServer } from 'node:net'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { buildAuthCotXml } from '../helpers/fakeAtakClient.js'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '../..')
const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
server.on('error', reject)
})
}
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
{ cwd: projectRoot, stdio: 'pipe' },
)
}
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(apiPort, timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${apiPort}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
clearTimeout(t)
if (res.ok) return baseURL
}
catch {
// try next
}
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${apiPort} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
apiPort: 0,
cotPort: 0,
}
beforeAll(async () => {
ensureDevCerts()
testState.apiPort = await getFreePort()
testState.cotPort = await getFreePort()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
}
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
PORT: String(testState.apiPort),
COT_PORT: String(testState.cotPort),
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
}
testState.serverProcess = spawn('node', ['.output/server/index.mjs'], {
cwd: projectRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(testState.apiPort, 90000)
}, 120000)
afterAll(() => {
if (testState.serverProcess?.pid) {
testState.serverProcess.kill('SIGTERM')
}
})
it('serves health on the configured API port', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${testState.apiPort}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
catch {
return tryProtocols(protocols.slice(1))
}
}
const res = await tryProtocols(['https', 'http'])
expect(res?.ok).toBe(true)
const body = await res.json()
expect(body).toHaveProperty('status', 'ok')
expect(body).toHaveProperty('endpoints')
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(testState.cotPort, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
s.setTimeout(8000, () => reject(new Error('connect timeout')))
})
await new Promise(r => setTimeout(r, 600))
expect(socket.destroyed).toBe(false)
socket.destroy()
})
})