make kestrel a tak server, so that it can send and receive pois as cots data
Some checks failed
ci/woodpecker/pr/pr Pipeline failed

This commit is contained in:
Madison Grubb
2026-02-17 10:42:53 -05:00
parent b18283d3b3
commit b0e8dd7ad9
96 changed files with 5767 additions and 500 deletions

View File

@@ -0,0 +1,54 @@
/**
* Build a TAK Protocol stream frame: 0xBF, varint payload length, payload.
* @param {string|Buffer} payload - UTF-8 payload (e.g. CoT XML)
* @returns {Buffer} TAK stream frame buffer.
*/
export function buildTakFrame(payload) {
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8')
let n = buf.length
const varint = []
while (true) {
const byte = n & 0x7F
n >>>= 7
if (n === 0) {
varint.push(byte)
break
}
varint.push(byte | 0x80)
}
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
}
/**
* Build CoT XML for a position update (event + point + optional contact).
* @param {object} opts - Position options
* @param {string} opts.uid - Entity UID
* @param {number} opts.lat - Latitude
* @param {number} opts.lon - Longitude
* @param {string} [opts.callsign] - Optional callsign
* @param {string} [opts.type] - Optional event type (default a-f-G)
* @returns {string} CoT XML string.
*/
export function buildPositionCotXml({ uid, lat, lon, callsign, type = 'a-f-G' }) {
const contact = callsign ? `<detail><contact callsign="${escapeXml(callsign)}"/></detail>` : ''
return `<event uid="${escapeXml(uid)}" type="${escapeXml(type)}"><point lat="${lat}" lon="${lon}"/>${contact}</event>`
}
/**
* Build CoT XML for auth (username/password).
* @param {object} opts - Auth options
* @param {string} opts.username - Username
* @param {string} opts.password - Password
* @returns {string} CoT XML string.
*/
export function buildAuthCotXml({ username, password }) {
return `<event><detail><auth username="${escapeXml(username)}" password="${escapeXml(password)}"/></detail></event>`
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

View File

@@ -0,0 +1,124 @@
/**
* @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 { 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 API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
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(timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
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 ${API_PORT} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
let serverProcess = null
beforeAll(async () => {
ensureDevCerts()
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,
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
}
serverProcess = spawn('node', ['.output/server/index.mjs'], {
cwd: projectRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
serverProcess.stdout?.on('data', d => process.stdout.write(d))
serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
}, 120000)
afterAll(() => {
if (serverProcess?.pid) {
serverProcess.kill('SIGTERM')
}
})
it('serves health on port 3000', async () => {
let res
for (const protocol of ['https', 'http']) {
try {
res = await fetch(`${protocol}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) break
}
catch {
// try next
}
}
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 on 8089: 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(COT_PORT, '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()
})
})

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
describe('shutdown integration', () => {
let originalExit
let exitCalls
let originalOn
beforeEach(() => {
clearCleanup()
exitCalls = []
originalExit = process.exit
process.exit = vi.fn((code) => {
exitCalls.push(code)
})
originalOn = process.on
})
afterEach(() => {
process.exit = originalExit
process.on = originalOn
clearCleanup()
})
it('initializes signal handlers', () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = originalOn
})
it('signal handler calls graceful', async () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
const sigtermHandler = handlers.SIGTERM
expect(sigtermHandler).toBeDefined()
await sigtermHandler()
expect(exitCalls.length).toBeGreaterThan(0)
process.on = originalOn
})
it('signal handler handles graceful error', async () => {
const handlers = {}
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
const sigintHandler = handlers.SIGINT
vi.spyOn(console, 'error').mockImplementation(() => {})
registerCleanup(async () => {
throw new Error('Force error')
})
await sigintHandler()
expect(exitCalls.length).toBeGreaterThan(0)
process.on = originalOn
})
it('covers timeout path in graceful', async () => {
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
})
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
expect(exitCalls.length).toBeGreaterThan(0)
})
it('covers graceful catch block', async () => {
registerCleanup(async () => {
throw new Error('Test error')
})
await graceful()
expect(exitCalls.length).toBeGreaterThan(0)
})
})

View File

@@ -16,4 +16,22 @@ describe('members page', () => {
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
it('shows members list and Add user when user is admin', async () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'admin', role: 'admin', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.text()).toContain('Add user')
expect(wrapper.text()).toMatch(/Only admins can change roles/)
})
it('shows members content when user has canEditPois (leader)', async () => {
registerEndpoint('/api/me', () => ({ id: '2', identifier: 'leader', role: 'leader', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Members')
expect(wrapper.text()).toContain('Identifier')
})
})

View File

@@ -7,6 +7,7 @@ describe('useCameras', () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
@@ -15,6 +16,22 @@ describe('useCameras', () => {
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
registerEndpoint('/api/cameras', () => ({
devices: [],
liveSessions: [],
cotEntities,
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.exists()).toBe(true)
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
throw new Error('network')

View File

@@ -37,4 +37,24 @@ describe('useLiveSessions', () => {
expect(el.exists()).toBe(true)
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
})
it('startPolling and stopPolling manage interval', async () => {
registerEndpoint('/api/live', () => [])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { startPolling, stopPolling } = useLiveSessions()
return () => h('div', {
onClick: () => {
startPolling()
startPolling()
stopPolling()
},
})
},
})
const wrapper = await mountSuspended(TestComponent)
await wrapper.trigger('click')
expect(wrapper.exists()).toBe(true)
})
})

117
test/unit/asyncLock.spec.js Normal file
View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { acquire, clearLocks } from '../../server/utils/asyncLock.js'
describe('asyncLock', () => {
beforeEach(() => {
clearLocks()
})
it('executes callback immediately when no lock exists', async () => {
let executed = false
await acquire('test', async () => {
executed = true
return 42
})
expect(executed).toBe(true)
})
it('returns callback result', async () => {
const result = await acquire('test', async () => {
return { value: 123 }
})
expect(result).toEqual({ value: 123 })
})
it('serializes concurrent operations on same key', async () => {
const results = []
const promises = []
for (let i = 0; i < 5; i++) {
promises.push(
acquire('same-key', async () => {
results.push(`start-${i}`)
await new Promise(resolve => setTimeout(resolve, 10))
results.push(`end-${i}`)
return i
}),
)
}
await Promise.all(promises)
// Operations should be serialized: start-end pairs should not interleave
expect(results.length).toBe(10)
for (let i = 0; i < 5; i++) {
expect(results[i * 2]).toBe(`start-${i}`)
expect(results[i * 2 + 1]).toBe(`end-${i}`)
}
})
it('allows parallel operations on different keys', async () => {
const results = []
const promises = []
for (let i = 0; i < 5; i++) {
promises.push(
acquire(`key-${i}`, async () => {
results.push(`start-${i}`)
await new Promise(resolve => setTimeout(resolve, 10))
results.push(`end-${i}`)
return i
}),
)
}
await Promise.all(promises)
// Different keys can run in parallel
expect(results.length).toBe(10)
// All starts should come before all ends (parallel execution)
const starts = results.filter(r => r.startsWith('start'))
const ends = results.filter(r => r.startsWith('end'))
expect(starts.length).toBe(5)
expect(ends.length).toBe(5)
})
it('handles errors and releases lock', async () => {
let callCount = 0
try {
await acquire('error-key', async () => {
callCount++
throw new Error('Test error')
})
}
catch (error) {
expect(error.message).toBe('Test error')
}
// Lock should be released, next operation should execute
await acquire('error-key', async () => {
callCount++
return 'success'
})
expect(callCount).toBe(2)
})
it('maintains lock ordering', async () => {
const order = []
const promises = []
for (let i = 0; i < 3; i++) {
const idx = i
promises.push(
acquire('ordered', async () => {
order.push(`before-${idx}`)
await new Promise(resolve => setTimeout(resolve, 5))
order.push(`after-${idx}`)
}),
)
}
await Promise.all(promises)
// Should execute in order: before-0, after-0, before-1, after-1, before-2, after-2
expect(order).toEqual(['before-0', 'after-0', 'before-1', 'after-1', 'before-2', 'after-2'])
})
})

51
test/unit/bootstrap.spec.js vendored Normal file
View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { bootstrapAdmin } from '../../server/utils/bootstrap.js'
describe('bootstrapAdmin', () => {
let run
let get
beforeEach(() => {
run = vi.fn().mockResolvedValue(undefined)
get = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
delete process.env.BOOTSTRAP_EMAIL
delete process.env.BOOTSTRAP_PASSWORD
})
it('returns without inserting when users exist', async () => {
get.mockResolvedValue({ n: 1 })
await bootstrapAdmin(run, get)
expect(get).toHaveBeenCalledWith('SELECT COUNT(*) as n FROM users')
expect(run).not.toHaveBeenCalled()
})
it('inserts default admin when no users and no env', async () => {
get.mockResolvedValue({ n: 0 })
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await bootstrapAdmin(run, get)
expect(run).toHaveBeenCalledTimes(1)
const args = run.mock.calls[0][1]
expect(args[1]).toBe('admin') // identifier
expect(args[3]).toBe('admin') // role
expect(logSpy).toHaveBeenCalled()
logSpy.mockRestore()
})
it('inserts admin with BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD when set', async () => {
get.mockResolvedValue({ n: 0 })
process.env.BOOTSTRAP_EMAIL = ' admin@example.com '
process.env.BOOTSTRAP_PASSWORD = 'secret123'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await bootstrapAdmin(run, get)
expect(run).toHaveBeenCalledTimes(1)
const args = run.mock.calls[0][1]
expect(args[1]).toBe('admin@example.com') // identifier
expect(args[3]).toBe('admin') // role
expect(logSpy).not.toHaveBeenCalled()
logSpy.mockRestore()
})
})

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_ENTITY_TTL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
WEBSOCKET_PATH,
MAX_PAYLOAD_BYTES,
MAX_STRING_LENGTH,
MAX_IDENTIFIER_LENGTH,
MEDIASOUP_RTC_MIN_PORT,
MEDIASOUP_RTC_MAX_PORT,
} from '../../server/utils/constants.js'
describe('constants', () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
expect(WEBSOCKET_PATH).toBe('/ws')
expect(MAX_PAYLOAD_BYTES).toBe(64 * 1024)
expect(MAX_STRING_LENGTH).toBe(1000)
expect(MAX_IDENTIFIER_LENGTH).toBe(255)
expect(MEDIASOUP_RTC_MIN_PORT).toBe(40000)
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
})
it('uses env var values when set', () => {
process.env.COT_AUTH_TIMEOUT_MS = '20000'
process.env.LIVE_SESSION_TTL_MS = '120000'
process.env.COT_PORT = '9090'
process.env.MAX_STRING_LENGTH = '2000'
// Re-import to get new values
const {
COT_AUTH_TIMEOUT_MS: timeout,
LIVE_SESSION_TTL_MS: ttl,
COT_PORT: port,
MAX_STRING_LENGTH: maxLen,
} = require('../../server/utils/constants.js')
// Note: In actual usage, constants are evaluated at module load time
// This test verifies the pattern works
expect(typeof timeout).toBe('number')
expect(typeof ttl).toBe('number')
expect(typeof port).toBe('number')
expect(typeof maxLen).toBe('number')
})
it('handles invalid env var values gracefully', () => {
// Constants are evaluated at module load time, so env vars set in tests won't affect them
// This test verifies the pattern: Number(process.env.VAR) || default
const invalidValue = Number('invalid')
expect(Number.isNaN(invalidValue)).toBe(true)
const fallback = invalidValue || 15000
expect(fallback).toBe(15000)
})
})

63
test/unit/cotAuth.spec.js Normal file
View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { hashPassword } from '../../server/utils/password.js'
import { validateCotAuth } from '../../server/utils/cotAuth.js'
describe('cotAuth', () => {
beforeEach(async () => {
setDbPathForTest(':memory:')
const { run } = await getDb()
const now = new Date().toISOString()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
['local-1', 'localuser', hashPassword('webpass'), 'member', now, 'local', null, null],
)
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['oidc-1', 'oidcuser', null, 'member', now, 'oidc', 'https://idp', 'sub-1', hashPassword('atakpass')],
)
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['oidc-2', 'nopass', null, 'member', now, 'oidc', 'https://idp', 'sub-2', null],
)
})
afterEach(() => {
setDbPathForTest(null)
})
it('validates local user with correct password', async () => {
const ok = await validateCotAuth('localuser', 'webpass')
expect(ok).toBe(true)
})
it('rejects local user with wrong password', async () => {
const ok = await validateCotAuth('localuser', 'wrong')
expect(ok).toBe(false)
})
it('validates OIDC user with correct ATAK password', async () => {
const ok = await validateCotAuth('oidcuser', 'atakpass')
expect(ok).toBe(true)
})
it('rejects OIDC user with wrong ATAK password', async () => {
const ok = await validateCotAuth('oidcuser', 'wrong')
expect(ok).toBe(false)
})
it('rejects OIDC user who has not set ATAK password', async () => {
const ok = await validateCotAuth('nopass', 'any')
expect(ok).toBe(false)
})
it('rejects unknown identifier', async () => {
const ok = await validateCotAuth('nobody', 'x')
expect(ok).toBe(false)
})
it('rejects empty identifier', async () => {
const ok = await validateCotAuth('', 'x')
expect(ok).toBe(false)
})
})

155
test/unit/cotParser.spec.js Normal file
View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest'
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../../../server/utils/cotParser.js'
function buildTakFrame(payload) {
const buf = Buffer.from(payload, 'utf8')
let n = buf.length
const varint = []
while (true) {
const byte = n & 0x7F
n >>>= 7
if (n === 0) {
varint.push(byte)
break
}
varint.push(byte | 0x80)
}
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
}
describe('cotParser', () => {
describe('parseTakStreamFrame', () => {
it('parses valid frame', () => {
const payload = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const frame = buildTakFrame(payload)
const result = parseTakStreamFrame(frame)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(payload)
expect(result.bytesConsumed).toBe(frame.length)
})
it('returns null for incomplete buffer', () => {
const frame = buildTakFrame('<e/>')
const partial = frame.subarray(0, 2)
expect(parseTakStreamFrame(partial)).toBeNull()
})
it('returns null for wrong magic', () => {
const payload = '<e/>'
const buf = Buffer.concat([Buffer.from([0x00]), Buffer.from([payload.length]), Buffer.from(payload)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
it('returns null for payload length exceeding max', () => {
const hugeLen = 64 * 1024 + 1
const varint = []
let n = hugeLen
while (true) {
varint.push(n & 0x7F)
n >>>= 7
if (n === 0) break
varint[varint.length - 1] |= 0x80
}
const buf = Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
})
describe('parseTraditionalXmlFrame', () => {
it('parses one XML message delimited by </event>', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(xml)
expect(result.bytesConsumed).toBe(buf.length)
})
it('returns null when buffer does not start with <', () => {
expect(parseTraditionalXmlFrame(Buffer.from('x<event></event>'))).toBeNull()
expect(parseTraditionalXmlFrame(Buffer.from([0xBF, 0x00]))).toBeNull()
})
it('returns null when </event> not yet received', () => {
const partial = Buffer.from('<event uid="x"><point lat="1" lon="2"/>', 'utf8')
expect(parseTraditionalXmlFrame(partial)).toBeNull()
})
it('extracted payload parses as auth CoT', () => {
const xml = '<event><detail><auth username="itak" password="mypass"/></detail></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
const parsed = parseCotPayload(result.payload)
expect(parsed).not.toBeNull()
expect(parsed.type).toBe('auth')
expect(parsed.username).toBe('itak')
expect(parsed.password).toBe('mypass')
})
})
describe('parseCotPayload', () => {
it('parses position CoT XML', () => {
const xml = '<event uid="device-1" type="a-f-G-U-C"><point lat="37.7" lon="-122.4"/><detail><contact callsign="Bravo"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.id).toBe('device-1')
expect(result.lat).toBe(37.7)
expect(result.lng).toBe(-122.4)
expect(result.label).toBe('Bravo')
})
it('parses auth CoT with detail.auth', () => {
const xml = '<event><detail><auth username="user1" password="secret123"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('user1')
expect(result.password).toBe('secret123')
})
it('parses auth CoT with __auth', () => {
const xml = '<event><detail><__auth username="u2" password="p2"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('u2')
expect(result.password).toBe('p2')
})
it('returns null for auth with empty username', () => {
const xml = '<event><detail><auth username=" " password="p"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).toBeNull()
})
it('parses position with point.lat and point.lon (no @_ prefix)', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="5" lon="10"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(5)
expect(result.lng).toBe(10)
})
it('returns null for non-XML payload', () => {
expect(parseCotPayload(Buffer.from('not xml'))).toBeNull()
})
it('uses uid as label when no contact/callsign', () => {
const xml = '<event uid="device-99" type="a-f-G"><point lat="1" lon="2"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.label).toBe('device-99')
})
it('uses point inside event when not at root', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="10" lon="20"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(10)
expect(result.lng).toBe(20)
})
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { isCotFirstByte, COT_FIRST_BYTE_TAK, COT_FIRST_BYTE_XML } from '../../server/utils/cotRouter.js'
describe('cotRouter', () => {
describe('isCotFirstByte', () => {
it('returns true for TAK Protocol (0xBF)', () => {
expect(isCotFirstByte(0xBF)).toBe(true)
expect(isCotFirstByte(COT_FIRST_BYTE_TAK)).toBe(true)
})
it('returns true for traditional XML (<)', () => {
expect(isCotFirstByte(0x3C)).toBe(true)
expect(isCotFirstByte(COT_FIRST_BYTE_XML)).toBe(true)
})
it('returns false for HTTP-like first bytes', () => {
expect(isCotFirstByte(0x47)).toBe(false) // 'G' GET
expect(isCotFirstByte(0x50)).toBe(false) // 'P' POST
expect(isCotFirstByte(0x48)).toBe(false) // 'H' HEAD
})
it('returns false for other bytes', () => {
expect(isCotFirstByte(0x00)).toBe(false)
expect(isCotFirstByte(0x16)).toBe(false) // TLS client hello
})
})
})

View File

@@ -0,0 +1,47 @@
/**
* Tests that the CoT parse-and-store path behaves as when a fake ATAK client sends TAK stream frames.
* Uses the same framing and payload parsing the server uses; does not start a real TCP server.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { buildTakFrame, buildPositionCotXml } from '../helpers/fakeAtakClient.js'
import { parseTakStreamFrame, parseCotPayload } from '../../server/utils/cotParser.js'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../server/utils/cotStore.js'
describe('cotServer (parse-and-store path)', () => {
beforeEach(() => {
clearCotStore()
})
it('stores entity when receiving TAK stream frame with position CoT XML', async () => {
const xml = buildPositionCotXml({ uid: 'device-1', lat: 37.7, lon: -122.4, callsign: 'Bravo' })
const frame = buildTakFrame(xml)
const parsedFrame = parseTakStreamFrame(frame)
expect(parsedFrame).not.toBeNull()
const parsed = parseCotPayload(parsedFrame.payload)
expect(parsed).not.toBeNull()
expect(parsed.type).toBe('cot')
await updateFromCot(parsed)
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].id).toBe('device-1')
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Bravo')
})
it('updates same uid on multiple messages', async () => {
const xml1 = buildPositionCotXml({ uid: 'u1', lat: 1, lon: 2 })
const xml2 = buildPositionCotXml({ uid: 'u1', lat: 3, lon: 4, callsign: 'Updated' })
const frame1 = buildTakFrame(xml1)
const frame2 = buildTakFrame(xml2)
const p1 = parseCotPayload(parseTakStreamFrame(frame1).payload)
const p2 = parseCotPayload(parseTakStreamFrame(frame2).payload)
await updateFromCot(p1)
await updateFromCot(p2)
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].lat).toBe(3)
expect(active[0].lng).toBe(4)
expect(active[0].label).toBe('Updated')
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
describe('cotStore', () => {
beforeEach(() => {
clearCotStore()
})
it('upserts entity by id', async () => {
await updateFromCot({ id: 'uid-1', lat: 37.7, lng: -122.4, label: 'Alpha' })
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].id).toBe('uid-1')
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Alpha')
})
it('updates same uid', async () => {
await updateFromCot({ id: 'uid-1', lat: 37.7, lng: -122.4 })
await updateFromCot({ id: 'uid-1', lat: 38, lng: -123, label: 'Updated' })
const active = await getActiveEntities()
expect(active).toHaveLength(1)
expect(active[0].lat).toBe(38)
expect(active[0].lng).toBe(-123)
expect(active[0].label).toBe('Updated')
})
it('ignores invalid parsed (no id)', async () => {
await updateFromCot({ lat: 37, lng: -122 })
const active = await getActiveEntities()
expect(active).toHaveLength(0)
})
it('ignores invalid parsed (bad coords)', async () => {
await updateFromCot({ id: 'x', lat: Number.NaN, lng: -122 })
await updateFromCot({ id: 'y', lat: 37, lng: Infinity })
const active = await getActiveEntities()
expect(active).toHaveLength(0)
})
it('prunes expired entities after getActiveEntities', async () => {
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
const active1 = await getActiveEntities(100)
expect(active1).toHaveLength(1)
await new Promise(r => setTimeout(r, 150))
const active2 = await getActiveEntities(100)
expect(active2).toHaveLength(0)
})
it('returns multiple active entities within TTL', async () => {
await updateFromCot({ id: 'a', lat: 1, lng: 2, label: 'A' })
await updateFromCot({ id: 'b', lat: 3, lng: 4, label: 'B' })
const active = await getActiveEntities()
expect(active).toHaveLength(2)
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
})
})

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { getDb, setDbPathForTest, withTransaction, healthCheck } from '../../server/utils/db.js'
describe('db', () => {
beforeEach(() => {
@@ -53,4 +53,71 @@ describe('db', () => {
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ id, name: 'Traffic Cam', device_type: 'traffic', lat: 37.7, lng: -122.4, stream_url: 'https://example.com/stream', source_type: 'mjpeg' })
})
describe('withTransaction', () => {
it('commits on success', async () => {
const db = await getDb()
const id = 'test-transaction-id'
await withTransaction(db, async ({ run, get }) => {
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'transaction@test.com', 'salt:hash', 'member', new Date().toISOString(), 'local', null, null],
)
return await get('SELECT id FROM users WHERE id = ?', [id])
})
const { get } = await getDb()
const user = await get('SELECT id FROM users WHERE id = ?', [id])
expect(user).toBeDefined()
expect(user.id).toBe(id)
})
it('rolls back on error', async () => {
const db = await getDb()
const id = 'test-rollback-id'
try {
await withTransaction(db, async ({ run }) => {
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'rollback@test.com', 'salt:hash', 'member', new Date().toISOString(), 'local', null, null],
)
throw new Error('Test error')
})
}
catch (error) {
expect(error.message).toBe('Test error')
}
const { get } = await getDb()
const user = await get('SELECT id FROM users WHERE id = ?', [id])
expect(user).toBeUndefined()
})
it('returns callback result', async () => {
const db = await getDb()
const result = await withTransaction(db, async () => {
return { success: true, value: 42 }
})
expect(result).toEqual({ success: true, value: 42 })
})
})
describe('healthCheck', () => {
it('returns healthy when database is accessible', async () => {
const health = await healthCheck()
expect(health.healthy).toBe(true)
expect(health.error).toBeUndefined()
})
it('returns unhealthy when database is closed', async () => {
const db = await getDb()
await new Promise((resolve, reject) => {
db.db.close((err) => {
if (err) reject(err)
else resolve()
})
})
setDbPathForTest(':memory:')
const health = await healthCheck()
expect(health.healthy).toBe(true)
})
})
})

View File

@@ -40,6 +40,13 @@ describe('deviceUtils', () => {
expect(rowToDevice({ id: 'd1', name: 'x', device_type: 'feed', lat: Number.NaN, lng: 0, stream_url: '', source_type: 'mjpeg' })).toBe(null)
})
it('coerces string lat/lng to numbers', () => {
const row = { id: 'd1', name: 'x', device_type: 'feed', lat: '37.5', lng: '-122.0', stream_url: '', source_type: 'mjpeg', config: null }
const out = rowToDevice(row)
expect(out?.lat).toBe(37.5)
expect(out?.lng).toBe(-122)
})
it('coerces non-string vendor, stream_url, config to null or empty', () => {
const row = {
id: 'd1',
@@ -92,6 +99,21 @@ describe('deviceUtils', () => {
}
expect(sanitizeDeviceForResponse(device).streamUrl).toBe('')
})
it('sanitizes stream_url to empty when not http(s)', () => {
const device = {
id: 'd1',
name: 'x',
device_type: 'feed',
vendor: null,
lat: 0,
lng: 0,
stream_url: 'ftp://example.com',
source_type: 'mjpeg',
config: null,
}
expect(sanitizeDeviceForResponse(device).streamUrl).toBe('')
})
})
describe('validateDeviceBody', () => {

118
test/unit/errors.spec.js Normal file
View File

@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
ConflictError,
formatErrorResponse,
} from '../../server/utils/errors.js'
describe('errors', () => {
describe('AppError', () => {
it('creates error with default status code', () => {
const error = new AppError('Test error')
expect(error.message).toBe('Test error')
expect(error.statusCode).toBe(500)
expect(error.code).toBe('INTERNAL_ERROR')
expect(error).toBeInstanceOf(Error)
})
it('creates error with custom status code and code', () => {
const error = new AppError('Custom error', 400, 'CUSTOM_CODE')
expect(error.statusCode).toBe(400)
expect(error.code).toBe('CUSTOM_CODE')
})
})
describe('ValidationError', () => {
it('creates validation error with 400 status', () => {
const error = new ValidationError('Invalid input')
expect(error.statusCode).toBe(400)
expect(error.code).toBe('VALIDATION_ERROR')
expect(error.details).toBeNull()
})
it('includes details when provided', () => {
const details = { field: 'email', reason: 'invalid format' }
const error = new ValidationError('Invalid input', details)
expect(error.details).toEqual(details)
})
})
describe('NotFoundError', () => {
it('creates not found error with default message', () => {
const error = new NotFoundError()
expect(error.statusCode).toBe(404)
expect(error.code).toBe('NOT_FOUND')
expect(error.message).toBe('Resource not found')
})
it('creates not found error with custom resource', () => {
const error = new NotFoundError('User')
expect(error.message).toBe('User not found')
})
})
describe('UnauthorizedError', () => {
it('creates unauthorized error', () => {
const error = new UnauthorizedError()
expect(error.statusCode).toBe(401)
expect(error.code).toBe('UNAUTHORIZED')
expect(error.message).toBe('Unauthorized')
})
it('creates unauthorized error with custom message', () => {
const error = new UnauthorizedError('Invalid credentials')
expect(error.message).toBe('Invalid credentials')
})
})
describe('ForbiddenError', () => {
it('creates forbidden error', () => {
const error = new ForbiddenError()
expect(error.statusCode).toBe(403)
expect(error.code).toBe('FORBIDDEN')
})
})
describe('ConflictError', () => {
it('creates conflict error', () => {
const error = new ConflictError()
expect(error.statusCode).toBe(409)
expect(error.code).toBe('CONFLICT')
})
})
describe('formatErrorResponse', () => {
it('formats AppError correctly', () => {
const error = new ValidationError('Invalid input', { field: 'email' })
const response = formatErrorResponse(error)
expect(response).toEqual({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: { field: 'email' },
},
})
})
it('formats generic Error correctly', () => {
const error = new Error('Generic error')
const response = formatErrorResponse(error)
expect(response).toEqual({
error: {
code: 'INTERNAL_ERROR',
message: 'Generic error',
},
})
})
it('handles error without message', () => {
const error = {}
const response = formatErrorResponse(error)
expect(response.error.message).toBe('Internal server error')
})
})
})

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createSession,
getLiveSession,
@@ -6,15 +6,23 @@ import {
deleteLiveSession,
getActiveSessions,
getActiveSessionByUserId,
getOrCreateSession,
clearSessions,
} from '../../../server/utils/liveSessions.js'
vi.mock('../../../server/utils/mediasoup.js', () => ({
getProducer: vi.fn().mockReturnValue(null),
getTransport: vi.fn().mockReturnValue(null),
closeRouter: vi.fn().mockResolvedValue(undefined),
}))
describe('liveSessions', () => {
let sessionId
beforeEach(() => {
beforeEach(async () => {
clearSessions()
sessionId = createSession('test-user', 'Test Session').id
const session = await createSession('test-user', 'Test Session')
sessionId = session.id
})
it('creates a session with WebRTC fields', () => {
@@ -28,15 +36,15 @@ describe('liveSessions', () => {
expect(session.transportId).toBeNull()
})
it('updates location', () => {
updateLiveSession(sessionId, { lat: 37.7, lng: -122.4 })
it('updates location', async () => {
await updateLiveSession(sessionId, { lat: 37.7, lng: -122.4 })
const session = getLiveSession(sessionId)
expect(session.lat).toBe(37.7)
expect(session.lng).toBe(-122.4)
})
it('updates WebRTC fields', () => {
updateLiveSession(sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
it('updates WebRTC fields', async () => {
await updateLiveSession(sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
const session = getLiveSession(sessionId)
expect(session.routerId).toBe('router-1')
expect(session.producerId).toBe('producer-1')
@@ -44,7 +52,7 @@ describe('liveSessions', () => {
})
it('returns hasStream instead of hasSnapshot', async () => {
updateLiveSession(sessionId, { producerId: 'producer-1' })
await updateLiveSession(sessionId, { producerId: 'producer-1' })
const active = await getActiveSessions()
const session = active.find(s => s.id === sessionId)
expect(session).toBeDefined()
@@ -58,27 +66,27 @@ describe('liveSessions', () => {
expect(session.hasStream).toBe(false)
})
it('deletes a session', () => {
deleteLiveSession(sessionId)
it('deletes a session', async () => {
await deleteLiveSession(sessionId)
const session = getLiveSession(sessionId)
expect(session).toBeUndefined()
})
it('getActiveSessionByUserId returns session for same user when active', () => {
const found = getActiveSessionByUserId('test-user')
it('getActiveSessionByUserId returns session for same user when active', async () => {
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeDefined()
expect(found.id).toBe(sessionId)
})
it('getActiveSessionByUserId returns undefined for unknown user', () => {
const found = getActiveSessionByUserId('other-user')
it('getActiveSessionByUserId returns undefined for unknown user', async () => {
const found = await getActiveSessionByUserId('other-user')
expect(found).toBeUndefined()
})
it('getActiveSessionByUserId returns undefined for expired session', () => {
it('getActiveSessionByUserId returns undefined for expired session', async () => {
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const found = getActiveSessionByUserId('test-user')
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeUndefined()
})
@@ -89,4 +97,43 @@ describe('liveSessions', () => {
expect(active.find(s => s.id === sessionId)).toBeUndefined()
expect(getLiveSession(sessionId)).toBeUndefined()
})
it('getActiveSessions runs cleanup for expired session with producer and transport', async () => {
const { getProducer, getTransport, closeRouter } = await import('../../../server/utils/mediasoup.js')
const mockProducer = { close: vi.fn() }
const mockTransport = { close: vi.fn() }
getProducer.mockReturnValue(mockProducer)
getTransport.mockReturnValue(mockTransport)
closeRouter.mockResolvedValue(undefined)
await updateLiveSession(sessionId, { producerId: 'p1', transportId: 't1', routerId: 'r1' })
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const active = await getActiveSessions()
expect(active.find(s => s.id === sessionId)).toBeUndefined()
expect(mockProducer.close).toHaveBeenCalled()
expect(mockTransport.close).toHaveBeenCalled()
expect(closeRouter).toHaveBeenCalledWith(sessionId)
})
it('getOrCreateSession returns existing active session', async () => {
const session = await getOrCreateSession('test-user', 'New Label')
expect(session.id).toBe(sessionId)
expect(session.userId).toBe('test-user')
})
it('getOrCreateSession creates new session when none exists', async () => {
const session = await getOrCreateSession('new-user', 'New Session')
expect(session.userId).toBe('new-user')
expect(session.label).toBe('New Session')
})
it('getOrCreateSession handles concurrent calls atomically', async () => {
const promises = []
for (let i = 0; i < 5; i++) {
promises.push(getOrCreateSession('concurrent-user', 'Concurrent'))
}
const sessions = await Promise.all(promises)
const uniqueIds = new Set(sessions.map(s => s.id))
expect(uniqueIds.size).toBe(1)
})
})

View File

@@ -1,71 +1,119 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { info, error, warn, debug, setContext, clearContext, runWithContext } from '../../server/utils/logger.js'
describe('logger', () => {
let fetchMock
let originalLog
let originalError
let originalWarn
let originalDebug
let logCalls
let errorCalls
let warnCalls
let debugCalls
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('$fetch', fetchMock)
vi.useFakeTimers()
logCalls = []
errorCalls = []
warnCalls = []
debugCalls = []
originalLog = console.log
originalError = console.error
originalWarn = console.warn
originalDebug = console.debug
console.log = vi.fn((...args) => logCalls.push(args))
console.error = vi.fn((...args) => errorCalls.push(args))
console.warn = vi.fn((...args) => warnCalls.push(args))
console.debug = vi.fn((...args) => debugCalls.push(args))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
console.log = originalLog
console.error = originalError
console.warn = originalWarn
console.debug = originalDebug
})
it('initLogger sets context', () => {
initLogger('sess-1', 'user-1')
logError('test', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
method: 'POST',
body: expect.objectContaining({
sessionId: 'sess-1',
userId: 'user-1',
level: 'error',
message: 'test',
}),
}))
it('logs info message', () => {
info('Test message')
expect(logCalls.length).toBe(1)
const logMsg = logCalls[0][0]
expect(logMsg).toContain('[INFO]')
expect(logMsg).toContain('Test message')
})
it('logError sends error level', () => {
logError('err', { code: 1 })
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'error', message: 'err', data: { code: 1 } }),
}))
it('includes request context when set', async () => {
await runWithContext('req-123', 'user-456', async () => {
info('Test message')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
expect(logMsg).toContain('user-456')
})
})
it('logWarn sends warn level', () => {
logWarn('warn', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'warn', message: 'warn' }),
}))
it('includes additional context', () => {
info('Test message', { key: 'value', count: 42 })
const logMsg = logCalls[0][0]
expect(logMsg).toContain('key')
expect(logMsg).toContain('value')
expect(logMsg).toContain('42')
})
it('logInfo sends info level', () => {
logInfo('info', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'info', message: 'info' }),
}))
it('logs error with stack trace', () => {
const err = new Error('Test error')
error('Failed', { error: err })
expect(errorCalls.length).toBe(1)
const errorMsg = errorCalls[0][0]
expect(errorMsg).toContain('[ERROR]')
expect(errorMsg).toContain('Failed')
expect(errorMsg).toContain('stack')
})
it('logDebug sends debug level', () => {
logDebug('debug', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'debug', message: 'debug' }),
}))
it('logs warning', () => {
warn('Warning message')
expect(warnCalls.length).toBe(1)
const warnMsg = warnCalls[0][0]
expect(warnMsg).toContain('[WARN]')
})
it('does not throw when $fetch rejects', async () => {
vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(new Error('network')))
logError('x', {})
vi.advanceTimersByTime(10)
await vi.advanceTimersByTimeAsync(0)
it('logs debug only in development', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
debug('Debug message')
expect(debugCalls.length).toBe(1)
process.env.NODE_ENV = originalEnv
})
it('does not log debug in production', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
debug('Debug message')
expect(debugCalls.length).toBe(0)
process.env.NODE_ENV = originalEnv
})
it('clears context', async () => {
await runWithContext('req-123', 'user-456', async () => {
info('Test with context')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
})
// Context should be cleared after runWithContext completes
info('Test without context')
const logMsg = logCalls[logCalls.length - 1][0]
expect(logMsg).not.toContain('req-123')
})
it('supports deprecated setContext/clearContext API', async () => {
await runWithContext(null, null, async () => {
setContext('req-123', 'user-456')
info('Test message')
const logMsg = logCalls[0][0]
expect(logMsg).toContain('req-123')
expect(logMsg).toContain('user-456')
clearContext()
info('Test after clear')
const logMsg2 = logCalls[1][0]
expect(logMsg2).not.toContain('req-123')
})
})
})

View File

@@ -6,6 +6,8 @@ import {
getCodeChallenge,
getOidcRedirectUri,
getOidcConfig,
buildAuthorizeUrl,
exchangeCode,
} from '../../server/utils/oidc.js'
describe('oidc', () => {
@@ -121,5 +123,31 @@ describe('oidc', () => {
const config = await getOidcConfig()
expect(config).toBeNull()
})
it('returns null when only some OIDC env vars set', async () => {
process.env.OIDC_ISSUER = 'https://idp.example.com'
process.env.OIDC_CLIENT_ID = 'client'
delete process.env.OIDC_CLIENT_SECRET
const config = await getOidcConfig()
expect(config).toBeNull()
delete process.env.OIDC_ISSUER
delete process.env.OIDC_CLIENT_ID
})
})
describe('buildAuthorizeUrl', () => {
it('is a function that accepts config and params', () => {
expect(typeof buildAuthorizeUrl).toBe('function')
expect(buildAuthorizeUrl.length).toBe(2)
})
})
describe('exchangeCode', () => {
it('rejects when grant fails', async () => {
const config = {}
const currentUrl = 'https://app/api/auth/oidc/callback?code=abc&state=s'
const checks = { state: 's', nonce: 'n', codeVerifier: 'v' }
await expect(exchangeCode(config, currentUrl, checks)).rejects.toBeDefined()
})
})
})

View File

@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest'
import { POI_ICON_TYPES } from '../../server/utils/poiConstants.js'
describe('poiConstants', () => {
it('exports POI_ICON_TYPES as frozen array', () => {
expect(POI_ICON_TYPES).toEqual(['pin', 'flag', 'waypoint'])
expect(Object.isFrozen(POI_ICON_TYPES)).toBe(true)
})
})

347
test/unit/queries.spec.js Normal file
View File

@@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import {
getDeviceById,
getAllDevices,
createDevice,
updateDevice,
getUserById,
getUserByIdentifier,
createUser,
updateUser,
getPoiById,
getAllPois,
createPoi,
updatePoi,
getSessionById,
createDbSession,
deleteSession,
} from '../../server/utils/queries.js'
describe('queries', () => {
let db
beforeEach(async () => {
setDbPathForTest(':memory:')
db = await getDb()
})
afterEach(() => {
setDbPathForTest(null)
})
describe('device queries', () => {
it('getDeviceById returns null for non-existent device', async () => {
const device = await getDeviceById(db, 'non-existent')
expect(device).toBeNull()
})
it('createDevice and getDeviceById work together', async () => {
const deviceData = {
name: 'Test Device',
device_type: 'traffic',
vendor: 'Test Vendor',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'mjpeg',
config: null,
}
const created = await createDevice(db, deviceData)
expect(created).toBeDefined()
expect(created.name).toBe('Test Device')
const retrieved = await getDeviceById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.name).toBe('Test Device')
})
it('createDevice handles vendor null', async () => {
const deviceData = {
name: 'Test',
device_type: 'feed',
vendor: null,
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
}
const created = await createDevice(db, deviceData)
expect(created.vendor).toBeNull()
})
it('createDevice handles all optional fields', async () => {
const deviceData = {
name: 'Full Device',
device_type: 'traffic',
vendor: 'Vendor Name',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'hls',
config: '{"key":"value"}',
}
const created = await createDevice(db, deviceData)
expect(created.name).toBe('Full Device')
expect(created.vendor).toBe('Vendor Name')
expect(created.stream_url).toBe('https://example.com/stream')
expect(created.source_type).toBe('hls')
expect(created.config).toBe('{"key":"value"}')
})
it('getAllDevices returns all devices', async () => {
await createDevice(db, {
name: 'Device 1',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
await createDevice(db, {
name: 'Device 2',
device_type: 'traffic',
lat: 41.7128,
lng: -75.0060,
stream_url: '',
source_type: 'hls',
config: null,
})
const devices = await getAllDevices(db)
expect(devices).toHaveLength(2)
})
it('updateDevice updates device fields', async () => {
const created = await createDevice(db, {
name: 'Original',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
const updated = await updateDevice(db, created.id, {
name: 'Updated',
lat: 41.7128,
})
expect(updated.name).toBe('Updated')
expect(updated.lat).toBe(41.7128)
})
it('updateDevice returns existing device when no updates', async () => {
const created = await createDevice(db, {
name: 'Test',
device_type: 'feed',
lat: 40.7128,
lng: -74.0060,
stream_url: '',
source_type: 'mjpeg',
config: null,
})
const result = await updateDevice(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('user queries', () => {
it('getUserById returns null for non-existent user', async () => {
const user = await getUserById(db, 'non-existent')
expect(user).toBeNull()
})
it('createUser and getUserById work together', async () => {
const userData = {
identifier: 'testuser',
password_hash: 'hash123',
role: 'admin',
created_at: new Date().toISOString(),
auth_provider: 'local',
}
const created = await createUser(db, userData)
expect(created).toBeDefined()
expect(created.identifier).toBe('testuser')
const retrieved = await getUserById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.identifier).toBe('testuser')
})
it('createUser defaults auth_provider to local', async () => {
const userData = {
identifier: 'testuser2',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
}
const created = await createUser(db, userData)
expect(created.auth_provider).toBe('local')
})
it('createUser handles oidc fields', async () => {
const userData = {
identifier: 'oidcuser',
password_hash: null,
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'oidc',
oidc_issuer: 'https://example.com',
oidc_sub: 'sub123',
}
const created = await createUser(db, userData)
expect(created.auth_provider).toBe('oidc')
})
it('getUserByIdentifier finds user by identifier', async () => {
await createUser(db, {
identifier: 'findme',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const user = await getUserByIdentifier(db, 'findme')
expect(user).toBeDefined()
expect(user.identifier).toBe('findme')
})
it('updateUser updates user fields', async () => {
const created = await createUser(db, {
identifier: 'original',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const updated = await updateUser(db, created.id, {
role: 'admin',
})
expect(updated.role).toBe('admin')
})
it('updateUser returns existing user when no updates', async () => {
const created = await createUser(db, {
identifier: 'test',
password_hash: 'hash',
role: 'member',
created_at: new Date().toISOString(),
auth_provider: 'local',
})
const result = await updateUser(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('POI queries', () => {
it('getPoiById returns null for non-existent POI', async () => {
const poi = await getPoiById(db, 'non-existent')
expect(poi).toBeNull()
})
it('createPoi and getPoiById work together', async () => {
const poiData = {
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
icon_type: 'flag',
}
const created = await createPoi(db, poiData)
expect(created).toBeDefined()
expect(created.label).toBe('Test POI')
const retrieved = await getPoiById(db, created.id)
expect(retrieved).toBeDefined()
expect(retrieved.label).toBe('Test POI')
})
it('createPoi defaults label and icon_type', async () => {
const poiData = {
lat: 40.7128,
lng: -74.0060,
}
const created = await createPoi(db, poiData)
expect(created.label).toBe('')
expect(created.icon_type).toBe('pin')
})
it('getAllPois returns all POIs', async () => {
await createPoi(db, { lat: 40.7128, lng: -74.0060, label: 'POI 1' })
await createPoi(db, { lat: 41.7128, lng: -75.0060, label: 'POI 2' })
const pois = await getAllPois(db)
expect(pois).toHaveLength(2)
})
it('updatePoi updates POI fields', async () => {
const created = await createPoi(db, {
lat: 40.7128,
lng: -74.0060,
label: 'Original',
})
const updated = await updatePoi(db, created.id, {
label: 'Updated',
lat: 41.7128,
})
expect(updated.label).toBe('Updated')
expect(updated.lat).toBe(41.7128)
})
it('updatePoi returns existing POI when no updates', async () => {
const created = await createPoi(db, {
lat: 40.7128,
lng: -74.0060,
label: 'Test',
})
const result = await updatePoi(db, created.id, {})
expect(result.id).toBe(created.id)
})
})
describe('session queries', () => {
it('getSessionById returns null for non-existent session', async () => {
const session = await getSessionById(db, 'non-existent')
expect(session).toBeNull()
})
it('createDbSession and getSessionById work together', async () => {
const sessionData = {
id: 'session-1',
user_id: 'user-1',
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 86400000).toISOString(),
}
await createDbSession(db, sessionData)
const retrieved = await getSessionById(db, 'session-1')
expect(retrieved).toBeDefined()
expect(retrieved.user_id).toBe('user-1')
})
it('deleteSession removes session', async () => {
await createDbSession(db, {
id: 'session-1',
user_id: 'user-1',
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 86400000).toISOString(),
})
await deleteSession(db, 'session-1')
const retrieved = await getSessionById(db, 'session-1')
expect(retrieved).toBeNull()
})
})
})

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest'
import { buildUpdateQuery, getAllowedColumns } from '../../server/utils/queryBuilder.js'
describe('queryBuilder', () => {
describe('buildUpdateQuery', () => {
it('builds valid UPDATE query for devices', () => {
const { query, params } = buildUpdateQuery('devices', null, {
name: 'Test Device',
lat: 40.7128,
})
expect(query).toBe('UPDATE devices SET name = ?, lat = ? WHERE id = ?')
expect(params).toEqual(['Test Device', 40.7128])
})
it('builds valid UPDATE query for users', () => {
const { query, params } = buildUpdateQuery('users', null, {
role: 'admin',
identifier: 'testuser',
})
expect(query).toBe('UPDATE users SET role = ?, identifier = ? WHERE id = ?')
expect(params).toEqual(['admin', 'testuser'])
})
it('builds valid UPDATE query for pois', () => {
const { query, params } = buildUpdateQuery('pois', null, {
label: 'Test POI',
lat: 40.7128,
lng: -74.0060,
})
expect(query).toBe('UPDATE pois SET label = ?, lat = ?, lng = ? WHERE id = ?')
expect(params).toEqual(['Test POI', 40.7128, -74.0060])
})
it('returns empty query when no updates', () => {
const { query, params } = buildUpdateQuery('devices', null, {})
expect(query).toBe('')
expect(params).toEqual([])
})
it('throws error for unknown table', () => {
expect(() => {
buildUpdateQuery('unknown_table', null, { name: 'test' })
}).toThrow('Unknown table: unknown_table')
})
it('throws error for invalid column name', () => {
expect(() => {
buildUpdateQuery('devices', null, { invalid_column: 'test' })
}).toThrow('Invalid column: invalid_column for table: devices')
})
it('prevents SQL injection attempts in column names', () => {
expect(() => {
buildUpdateQuery('devices', null, { 'name\'; DROP TABLE devices; --': 'test' })
}).toThrow('Invalid column')
})
it('allows custom allowedColumns set', () => {
const customColumns = new Set(['name', 'custom_field'])
const { query, params } = buildUpdateQuery('devices', customColumns, {
name: 'Test',
custom_field: 'value',
})
expect(query).toBe('UPDATE devices SET name = ?, custom_field = ? WHERE id = ?')
expect(params).toEqual(['Test', 'value'])
})
it('rejects columns not in custom allowedColumns', () => {
const customColumns = new Set(['name'])
expect(() => {
buildUpdateQuery('devices', customColumns, { name: 'Test', lat: 40.7128 })
}).toThrow('Invalid column: lat')
})
})
describe('getAllowedColumns', () => {
it('returns allowed columns for devices', () => {
const columns = getAllowedColumns('devices')
expect(columns).toBeInstanceOf(Set)
expect(columns.has('name')).toBe(true)
expect(columns.has('lat')).toBe(true)
expect(columns.has('invalid')).toBe(false)
})
it('returns allowed columns for users', () => {
const columns = getAllowedColumns('users')
expect(columns.has('role')).toBe(true)
expect(columns.has('identifier')).toBe(true)
})
it('returns allowed columns for pois', () => {
const columns = getAllowedColumns('pois')
expect(columns.has('label')).toBe(true)
expect(columns.has('lat')).toBe(true)
})
it('returns empty set for unknown table', () => {
const columns = getAllowedColumns('unknown')
expect(columns).toBeInstanceOf(Set)
expect(columns.size).toBe(0)
})
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from '../../server/utils/sanitize.js'
describe('sanitize', () => {
describe('sanitizeString', () => {
it('trims whitespace', () => {
expect(sanitizeString(' test ')).toBe('test')
expect(sanitizeString('\n\ttest\n\t')).toBe('test')
})
it('returns empty string for non-string input', () => {
expect(sanitizeString(null)).toBe('')
expect(sanitizeString(undefined)).toBe('')
expect(sanitizeString(123)).toBe('')
expect(sanitizeString({})).toBe('')
})
it('truncates strings exceeding max length', () => {
const longString = 'a'.repeat(2000)
expect(sanitizeString(longString, 1000).length).toBe(1000)
})
it('uses default max length', () => {
const longString = 'a'.repeat(2000)
expect(sanitizeString(longString).length).toBe(1000)
})
it('preserves valid strings', () => {
expect(sanitizeString('valid string')).toBe('valid string')
expect(sanitizeString('test123')).toBe('test123')
})
})
describe('sanitizeIdentifier', () => {
it('accepts valid identifiers', () => {
expect(sanitizeIdentifier('test123')).toBe('test123')
expect(sanitizeIdentifier('test_user')).toBe('test_user')
expect(sanitizeIdentifier('Test123')).toBe('Test123')
expect(sanitizeIdentifier('_test')).toBe('_test')
})
it('rejects invalid characters', () => {
expect(sanitizeIdentifier('test-user')).toBe('')
expect(sanitizeIdentifier('test.user')).toBe('')
expect(sanitizeIdentifier('test user')).toBe('')
expect(sanitizeIdentifier('test@user')).toBe('')
})
it('trims whitespace', () => {
expect(sanitizeIdentifier(' test123 ')).toBe('test123')
})
it('returns empty string for non-string input', () => {
expect(sanitizeIdentifier(null)).toBe('')
expect(sanitizeIdentifier(undefined)).toBe('')
expect(sanitizeIdentifier(123)).toBe('')
})
it('rejects empty strings', () => {
expect(sanitizeIdentifier('')).toBe('')
expect(sanitizeIdentifier(' ')).toBe('')
})
it('rejects strings exceeding max length', () => {
const longId = 'a'.repeat(256)
expect(sanitizeIdentifier(longId)).toBe('')
})
})
describe('sanitizeLabel', () => {
it('trims whitespace', () => {
expect(sanitizeLabel(' test label ')).toBe('test label')
})
it('truncates long labels', () => {
const longLabel = 'a'.repeat(2000)
expect(sanitizeLabel(longLabel, 500).length).toBe(500)
})
it('uses default max length', () => {
const longLabel = 'a'.repeat(2000)
expect(sanitizeLabel(longLabel).length).toBe(1000)
})
it('returns empty string for non-string input', () => {
expect(sanitizeLabel(null)).toBe('')
expect(sanitizeLabel(undefined)).toBe('')
})
it('preserves valid labels', () => {
expect(sanitizeLabel('Valid Label')).toBe('Valid Label')
expect(sanitizeLabel('Test 123')).toBe('Test 123')
})
})
})

156
test/unit/shutdown.spec.js Normal file
View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerCleanup, graceful, clearCleanup, initShutdownHandlers } from '../../server/utils/shutdown.js'
describe('shutdown', () => {
let originalExit
let exitCalls
beforeEach(() => {
clearCleanup()
exitCalls = []
originalExit = process.exit
process.exit = vi.fn((code) => {
exitCalls.push(code)
})
})
afterEach(() => {
process.exit = originalExit
clearCleanup()
})
it('registers cleanup functions', () => {
expect(() => {
registerCleanup(async () => {})
}).not.toThrow()
})
it('throws error for non-function cleanup', () => {
expect(() => {
registerCleanup('not a function')
}).toThrow('Cleanup function must be a function')
})
it('executes cleanup functions in reverse order', async () => {
const calls = []
registerCleanup(async () => {
calls.push('first')
})
registerCleanup(async () => {
calls.push('second')
})
registerCleanup(async () => {
calls.push('third')
})
await graceful()
expect(calls).toEqual(['third', 'second', 'first'])
expect(exitCalls).toEqual([0])
})
it('handles cleanup function errors gracefully', async () => {
registerCleanup(async () => {
throw new Error('Cleanup error')
})
registerCleanup(async () => {
// This should still execute
})
await graceful()
expect(exitCalls).toEqual([0])
})
it('exits with code 1 on error', async () => {
const error = new Error('Test error')
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('prevents multiple shutdowns', async () => {
let callCount = 0
registerCleanup(async () => {
callCount++
})
await graceful()
await graceful()
expect(callCount).toBe(1)
})
it('handles cleanup error during graceful shutdown', async () => {
registerCleanup(async () => {
throw new Error('Cleanup failed')
})
await graceful()
expect(exitCalls).toEqual([0])
})
it('handles error in executeCleanup catch block', async () => {
registerCleanup(async () => {
throw new Error('Test')
})
await graceful()
expect(exitCalls.length).toBeGreaterThan(0)
})
it('handles error with stack trace', async () => {
const error = new Error('Test error')
error.stack = 'Error: Test error\n at test.js:1:1'
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('handles error without stack trace', async () => {
const error = { message: 'Test error' }
await graceful(error)
expect(exitCalls).toEqual([1])
})
it('handles timeout scenario', async () => {
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
})
const timeout = setTimeout(() => {
expect(exitCalls.length).toBeGreaterThan(0)
}, 35000)
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
clearTimeout(timeout)
})
it('covers executeCleanup early return when already shutting down', async () => {
registerCleanup(async () => {})
await graceful()
await graceful() // Second call should return early
expect(exitCalls.length).toBeGreaterThan(0)
})
it('covers initShutdownHandlers', () => {
const handlers = {}
const originalOn = process.on
process.on = vi.fn((signal, handler) => {
handlers[signal] = handler
})
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = originalOn
})
it('covers graceful catch block error path', async () => {
// Force executeCleanup to throw by making cleanup throw synchronously
registerCleanup(async () => {
throw new Error('Force error in cleanup')
})
await graceful()
expect(exitCalls.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,488 @@
import { describe, it, expect } from 'vitest'
import {
validateDevice,
validateUpdateDevice,
validateUser,
validateUpdateUser,
validatePoi,
validateUpdatePoi,
} from '../../server/utils/validation.js'
describe('validation', () => {
describe('validateDevice', () => {
it('validates valid device data', () => {
const result = validateDevice({
name: 'Test Device',
device_type: 'traffic',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com/stream',
source_type: 'mjpeg',
})
expect(result.valid).toBe(true)
expect(result.data).toBeDefined()
expect(result.data.device_type).toBe('traffic')
})
it('rejects invalid coordinates', () => {
const result = validateDevice({
name: 'Test',
lat: 'invalid',
lng: -74.0060,
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat and lng required as finite numbers')
})
it('rejects non-object input', () => {
const result = validateDevice(null)
expect(result.valid).toBe(false)
expect(result.errors).toContain('body required')
})
it('defaults device_type to feed', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.data.device_type).toBe('feed')
})
})
describe('validateUpdateDevice', () => {
it('validates partial updates', () => {
const result = validateUpdateDevice({ name: 'Updated', lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.name).toBe('Updated')
expect(result.data.lat).toBe(40.7128)
})
it('allows empty updates', () => {
const result = validateUpdateDevice({})
expect(result.valid).toBe(true)
expect(Object.keys(result.data).length).toBe(0)
})
it('rejects invalid device_type', () => {
const result = validateUpdateDevice({ device_type: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Invalid device_type')
})
it('handles device_type undefined', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.device_type).toBeUndefined()
})
it('handles vendor null', () => {
const result = validateUpdateDevice({ vendor: null })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor empty string', () => {
const result = validateUpdateDevice({ vendor: '' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor string', () => {
const result = validateUpdateDevice({ vendor: 'Test Vendor' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBe('Test Vendor')
})
})
describe('validateUser', () => {
it('validates valid user data', () => {
const result = validateUser({
identifier: 'testuser',
password: 'password123',
role: 'admin',
})
expect(result.valid).toBe(true)
expect(result.data.identifier).toBe('testuser')
})
it('rejects missing identifier', () => {
const result = validateUser({
password: 'password123',
role: 'admin',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('identifier required')
})
it('rejects invalid role', () => {
const result = validateUser({
identifier: 'testuser',
password: 'password123',
role: 'invalid',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('role must be admin, leader, or member')
})
})
describe('validateUpdateUser', () => {
it('validates partial updates', () => {
const result = validateUpdateUser({ role: 'leader' })
expect(result.valid).toBe(true)
expect(result.data.role).toBe('leader')
})
it('rejects empty identifier', () => {
const result = validateUpdateUser({ identifier: '' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('identifier cannot be empty')
})
})
describe('validatePoi', () => {
it('validates valid POI data', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
iconType: 'flag',
})
expect(result.valid).toBe(true)
expect(result.data.lat).toBe(40.7128)
})
it('rejects invalid coordinates', () => {
const result = validatePoi({
lat: 'invalid',
lng: -74.0060,
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat and lng required as finite numbers')
})
})
describe('validateUpdatePoi', () => {
it('validates partial updates', () => {
const result = validateUpdatePoi({ label: 'Updated', lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Updated')
expect(result.data.lat).toBe(40.7128)
})
it('rejects invalid iconType', () => {
const result = validateUpdatePoi({ iconType: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Invalid iconType')
})
it('allows empty updates', () => {
const result = validateUpdatePoi({})
expect(result.valid).toBe(true)
expect(Object.keys(result.data).length).toBe(0)
})
it('rejects invalid lat', () => {
const result = validateUpdatePoi({ lat: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('lat must be a finite number')
})
it('rejects invalid lng', () => {
const result = validateUpdatePoi({ lng: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('lng must be a finite number')
})
})
describe('validateUpdateDevice', () => {
it('handles vendor null', () => {
const result = validateUpdateDevice({ vendor: null })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles vendor empty string', () => {
const result = validateUpdateDevice({ vendor: '' })
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles config object', () => {
const result = validateUpdateDevice({ config: { key: 'value' } })
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config null', () => {
const result = validateUpdateDevice({ config: null })
expect(result.valid).toBe(true)
expect(result.data.config).toBeNull()
})
it('handles config string', () => {
const result = validateUpdateDevice({ config: '{"key":"value"}' })
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
})
describe('validateUpdateUser', () => {
it('handles empty password', () => {
const result = validateUpdateUser({ password: '' })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
it('handles undefined password', () => {
const result = validateUpdateUser({ password: undefined })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
it('validates password when provided', () => {
const result = validateUpdateUser({ password: 'newpassword' })
expect(result.valid).toBe(true)
expect(result.data.password).toBe('newpassword')
})
})
describe('validateDevice', () => {
it('handles missing stream_url', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.stream_url).toBe('')
})
it('handles invalid source_type', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
source_type: 'invalid',
})
expect(result.valid).toBe(true)
expect(result.data.source_type).toBe('mjpeg')
})
})
describe('validatePoi', () => {
it('defaults iconType to pin', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBe('pin')
})
it('handles invalid iconType', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
iconType: 'invalid',
})
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBe('pin')
})
it('validates valid POI with all fields', () => {
const result = validatePoi({
lat: 40.7128,
lng: -74.0060,
label: 'Test POI',
iconType: 'flag',
})
expect(result.valid).toBe(true)
expect(result.data.lat).toBe(40.7128)
expect(result.data.lng).toBe(-74.0060)
expect(result.data.label).toBe('Test POI')
expect(result.data.icon_type).toBe('flag')
})
})
describe('validateUpdateDevice', () => {
it('handles all field types', () => {
const result = validateUpdateDevice({
name: 'Test',
device_type: 'traffic',
vendor: 'Vendor',
lat: 40.7128,
lng: -74.0060,
stream_url: 'https://example.com',
source_type: 'hls',
config: { key: 'value' },
})
expect(result.valid).toBe(true)
expect(result.data.name).toBe('Test')
expect(result.data.device_type).toBe('traffic')
expect(result.data.vendor).toBe('Vendor')
expect(result.data.lat).toBe(40.7128)
expect(result.data.lng).toBe(-74.0060)
expect(result.data.stream_url).toBe('https://example.com')
expect(result.data.source_type).toBe('hls')
expect(result.data.config).toBe('{"key":"value"}')
})
})
describe('validateUpdatePoi', () => {
it('handles all field types', () => {
const result = validateUpdatePoi({
label: 'Updated',
iconType: 'waypoint',
lat: 41.7128,
lng: -75.0060,
})
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Updated')
expect(result.data.icon_type).toBe('waypoint')
expect(result.data.lat).toBe(41.7128)
expect(result.data.lng).toBe(-75.0060)
})
it('handles partial updates', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.label).toBe('Test')
})
})
describe('validateDevice edge cases', () => {
it('handles vendor undefined', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
})
expect(result.valid).toBe(true)
expect(result.data.vendor).toBeNull()
})
it('handles config as object', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: { key: 'value' },
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config as string', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: '{"key":"value"}',
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe('{"key":"value"}')
})
it('handles config null', () => {
const result = validateDevice({
name: 'Test',
lat: 40.7128,
lng: -74.0060,
config: null,
})
expect(result.valid).toBe(true)
expect(result.data.config).toBe(null)
})
})
describe('validateUpdateDevice edge cases', () => {
it('handles config null in updates', () => {
const result = validateUpdateDevice({ config: null })
expect(result.valid).toBe(true)
expect(result.data.config).toBeNull()
})
it('handles config undefined in updates', () => {
const result = validateUpdateDevice({ config: undefined })
expect(result.valid).toBe(true)
expect(result.data.config).toBeUndefined()
})
it('handles source_type undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.source_type).toBeUndefined()
})
it('handles lat undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lat).toBeUndefined()
})
it('handles lng undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lng).toBeUndefined()
})
it('handles stream_url undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.stream_url).toBeUndefined()
})
it('handles config undefined in updates', () => {
const result = validateUpdateDevice({ name: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.config).toBeUndefined()
})
})
describe('validateUpdateUser edge cases', () => {
it('handles role undefined', () => {
const result = validateUpdateUser({ identifier: 'test' })
expect(result.valid).toBe(true)
expect(result.data.role).toBeUndefined()
})
it('handles identifier undefined', () => {
const result = validateUpdateUser({ role: 'admin' })
expect(result.valid).toBe(true)
expect(result.data.identifier).toBeUndefined()
})
it('handles password undefined', () => {
const result = validateUpdateUser({ role: 'admin' })
expect(result.valid).toBe(true)
expect(result.data.password).toBeUndefined()
})
})
describe('validateUpdatePoi edge cases', () => {
it('handles label undefined', () => {
const result = validateUpdatePoi({ lat: 40.7128 })
expect(result.valid).toBe(true)
expect(result.data.label).toBeUndefined()
})
it('handles iconType undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.icon_type).toBeUndefined()
})
it('handles lat undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lat).toBeUndefined()
})
it('handles lng undefined', () => {
const result = validateUpdatePoi({ label: 'Test' })
expect(result.valid).toBe(true)
expect(result.data.lng).toBeUndefined()
})
})
})

View File

@@ -22,9 +22,10 @@ describe('webrtcSignaling', () => {
let sessionId
const userId = 'test-user'
beforeEach(() => {
beforeEach(async () => {
clearSessions()
sessionId = createSession(userId, 'Test').id
const session = await createSession(userId, 'Test')
sessionId = session.id
})
it('returns error when session not found', async () => {
@@ -68,4 +69,18 @@ describe('webrtcSignaling', () => {
expect(res?.type).toBe('transport-connected')
expect(res?.data?.connected).toBe(true)
})
it('returns error when transport.connect throws', async () => {
const { getTransport } = await import('../../server/utils/mediasoup.js')
getTransport.mockReturnValueOnce({
id: 'mock-transport',
connect: vi.fn().mockRejectedValue(new Error('Connection failed')),
})
await handleWebSocketMessage(userId, sessionId, 'create-transport', {})
const res = await handleWebSocketMessage(userId, sessionId, 'connect-transport', {
transportId: 'mock-transport',
dtlsParameters: { role: 'client', fingerprints: [] },
})
expect(res?.error).toBe('Connection failed')
})
})