initial commit

This commit is contained in:
Madison Grubb
2026-02-10 23:32:26 -05:00
commit b7046dc0e6
133 changed files with 26080 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, afterEach } from 'vitest'
import { getAuthConfig } from '../../server/utils/authConfig.js'
describe('authConfig', () => {
const origEnv = { ...process.env }
afterEach(() => {
process.env = { ...origEnv }
})
it('returns oidc disabled when OIDC env vars are unset', () => {
delete process.env.OIDC_ISSUER
delete process.env.OIDC_CLIENT_ID
delete process.env.OIDC_CLIENT_SECRET
expect(getAuthConfig()).toEqual({
oidc: { enabled: false, label: '' },
})
})
it('returns oidc disabled when only some OIDC vars are set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
delete process.env.OIDC_CLIENT_SECRET
expect(getAuthConfig().oidc.enabled).toBe(false)
})
it('returns oidc enabled and default label when all OIDC vars are set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
process.env.OIDC_CLIENT_SECRET = 'secret'
const config = getAuthConfig()
expect(config.oidc.enabled).toBe(true)
expect(config.oidc.label).toBe('Sign in with OIDC')
})
it('uses OIDC_LABEL when set', () => {
process.env.OIDC_ISSUER = 'https://auth.example.com'
process.env.OIDC_CLIENT_ID = 'client'
process.env.OIDC_CLIENT_SECRET = 'secret'
process.env.OIDC_LABEL = 'Sign in with Authentik'
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
})
})

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { requireAuth } from '../../server/utils/authHelpers.js'
function mockEvent(user = null) {
return { context: { user } }
}
describe('authHelpers', () => {
it('requireAuth throws 401 when no user', () => {
const event = mockEvent()
expect(() => requireAuth(event)).toThrow()
try {
requireAuth(event)
}
catch (e) {
expect(e.statusCode).toBe(401)
}
})
it('requireAuth returns user when set', () => {
const user = { id: '1', identifier: 'a@b.com', role: 'member' }
const event = mockEvent(user)
expect(requireAuth(event)).toEqual(user)
})
it('requireAuth with adminOrLeader throws 403 for member', () => {
const event = mockEvent({ id: '1', identifier: 'a', role: 'member' })
expect(() => requireAuth(event, { role: 'adminOrLeader' })).toThrow()
try {
requireAuth(event, { role: 'adminOrLeader' })
}
catch (e) {
expect(e.statusCode).toBe(403)
}
})
it('requireAuth with adminOrLeader returns user for admin', () => {
const user = { id: '1', identifier: 'a', role: 'admin' }
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
})
it('requireAuth with adminOrLeader returns user for leader', () => {
const user = { id: '1', identifier: 'a', role: 'leader' }
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
})
it('requireAuth with admin throws 403 for leader', () => {
const event = mockEvent({ id: '1', identifier: 'a', role: 'leader' })
try {
requireAuth(event, { role: 'admin' })
}
catch (e) {
expect(e.statusCode).toBe(403)
}
})
it('requireAuth with admin returns user for admin', () => {
const user = { id: '1', identifier: 'a', role: 'admin' }
expect(requireAuth(mockEvent(user), { role: 'admin' })).toEqual(user)
})
})

View File

@@ -0,0 +1,42 @@
/**
* Ensures no API route that requires auth (requireAuth with optional role)
* is in the auth skip list. When adding a new protected API, add its path prefix to
* PROTECTED_PATH_PREFIXES in server/utils/authSkipPaths.js so these tests fail if it gets skipped.
*/
import { describe, it, expect } from 'vitest'
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authSkipPaths.js'
describe('authSkipPaths', () => {
it('does not skip any protected path (auth required for these)', () => {
for (const path of PROTECTED_PATH_PREFIXES) {
expect(skipAuth(path)).toBe(false)
}
// Also check a concrete path under each prefix
expect(skipAuth('/api/cameras')).toBe(false)
expect(skipAuth('/api/devices')).toBe(false)
expect(skipAuth('/api/devices/any-id')).toBe(false)
expect(skipAuth('/api/me')).toBe(false)
expect(skipAuth('/api/pois')).toBe(false)
expect(skipAuth('/api/pois/any-id')).toBe(false)
expect(skipAuth('/api/users')).toBe(false)
expect(skipAuth('/api/users/any-id')).toBe(false)
})
it('skips known public paths', () => {
expect(skipAuth('/api/auth/login')).toBe(true)
expect(skipAuth('/api/auth/logout')).toBe(true)
expect(skipAuth('/api/auth/config')).toBe(true)
expect(skipAuth('/api/auth/oidc/authorize')).toBe(true)
expect(skipAuth('/api/auth/oidc/callback')).toBe(true)
expect(skipAuth('/api/health')).toBe(true)
expect(skipAuth('/api/health/ready')).toBe(true)
expect(skipAuth('/health')).toBe(true)
})
it('keeps SKIP_PATHS and PROTECTED_PATH_PREFIXES disjoint', () => {
const skipSet = new Set(SKIP_PATHS)
for (const path of PROTECTED_PATH_PREFIXES) {
expect(skipSet.has(path)).toBe(false)
}
})
})

56
test/unit/db.spec.js Normal file
View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
describe('db', () => {
beforeEach(() => {
setDbPathForTest(':memory:')
})
afterEach(() => {
setDbPathForTest(null)
})
it('creates tables and returns db', async () => {
const conn = await getDb()
expect(conn).toBeDefined()
expect(conn.run).toBeDefined()
expect(conn.all).toBeDefined()
expect(conn.get).toBeDefined()
})
it('inserts and reads user', async () => {
const { run, get } = await getDb()
const id = 'test-user-id'
const now = new Date().toISOString()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'test@test.com', 'salt:hash', 'member', now, 'local', null, null],
)
const row = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
expect(row).toEqual({ id, identifier: 'test@test.com', role: 'member' })
})
it('inserts and reads poi', async () => {
const { run, all } = await getDb()
const id = 'test-poi-id'
await run(
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
[id, 37.7, -122.4, 'Test', 'pin'],
)
const rows = await all('SELECT id, lat, lng, label, icon_type FROM pois')
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ id, lat: 37.7, lng: -122.4, label: 'Test', icon_type: 'pin' })
})
it('inserts and reads device', async () => {
const { run, all } = await getDb()
const id = 'test-device-id'
await run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, 'Traffic Cam', 'traffic', null, 37.7, -122.4, 'https://example.com/stream', 'mjpeg', null],
)
const rows = await all('SELECT id, name, device_type, lat, lng, stream_url, source_type FROM devices')
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' })
})
})

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rowToDevice, sanitizeDeviceForResponse, validateDeviceBody, DEVICE_TYPES, SOURCE_TYPES } from '../../server/utils/deviceUtils.js'
describe('deviceUtils', () => {
describe('rowToDevice', () => {
it('returns device row for valid db row', () => {
const row = {
id: 'd1',
name: 'Cam 1',
device_type: 'feed',
vendor: null,
lat: 37.7,
lng: -122.4,
stream_url: 'https://example.com/mjpeg',
source_type: 'mjpeg',
config: null,
}
expect(rowToDevice(row)).toEqual({ ...row, lat: 37.7, lng: -122.4 })
})
it('normalizes source_type to mjpeg when invalid', () => {
const row = { id: 'd1', name: 'x', device_type: 'feed', vendor: null, lat: 0, lng: 0, stream_url: '', source_type: 'invalid', config: null }
expect(rowToDevice(row)?.source_type).toBe('mjpeg')
})
it('accepts hls source_type', () => {
const row = { id: 'd1', name: 'x', device_type: 'feed', vendor: null, lat: 0, lng: 0, stream_url: '', source_type: 'hls', config: null }
expect(rowToDevice(row)?.source_type).toBe('hls')
})
it('returns null for null row', () => {
expect(rowToDevice(null)).toBe(null)
})
it('returns null for row missing id', () => {
expect(rowToDevice({ name: 'x', device_type: 'feed', lat: 0, lng: 0 })).toBe(null)
})
it('returns null for invalid lat/lng', () => {
expect(rowToDevice({ id: 'd1', name: 'x', device_type: 'feed', lat: Number.NaN, lng: 0, stream_url: '', source_type: 'mjpeg' })).toBe(null)
})
it('coerces non-string vendor, stream_url, config to null or empty', () => {
const row = {
id: 'd1',
name: 'x',
device_type: 'feed',
vendor: 123,
lat: 0,
lng: 0,
stream_url: null,
source_type: 'mjpeg',
config: 456,
}
const out = rowToDevice(row)
expect(out?.vendor).toBe(null)
expect(out?.stream_url).toBe('')
expect(out?.config).toBe(null)
})
})
describe('sanitizeDeviceForResponse', () => {
it('returns camelCase streamUrl and sourceType', () => {
const device = {
id: 'd1',
name: 'Cam',
device_type: 'traffic',
vendor: null,
lat: 1,
lng: 2,
stream_url: 'https://example.com/stream',
source_type: 'mjpeg',
config: null,
}
const out = sanitizeDeviceForResponse(device)
expect(out.streamUrl).toBe('https://example.com/stream')
expect(out.sourceType).toBe('mjpeg')
expect(out).not.toHaveProperty('stream_url')
})
it('sanitizes stream_url to empty for javascript:', () => {
const device = {
id: 'd1',
name: 'x',
device_type: 'feed',
vendor: null,
lat: 0,
lng: 0,
stream_url: 'javascript:alert(1)',
source_type: 'mjpeg',
config: null,
}
expect(sanitizeDeviceForResponse(device).streamUrl).toBe('')
})
})
describe('validateDeviceBody', () => {
beforeEach(() => {
vi.stubGlobal('createError', (opts) => {
const err = new Error(opts?.message || 'Error')
err.statusCode = opts?.statusCode
throw err
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns normalized body for valid input', () => {
const body = {
name: ' My Cam ',
device_type: 'alpr',
lat: 37,
lng: -122,
stream_url: 'https://x.com/s',
source_type: 'hls',
}
const out = validateDeviceBody(body)
expect(out.name).toBe('My Cam')
expect(out.device_type).toBe('alpr')
expect(out.lat).toBe(37)
expect(out.lng).toBe(-122)
expect(out.stream_url).toBe('https://x.com/s')
expect(out.source_type).toBe('hls')
expect(out.vendor).toBe(null)
expect(out.config).toBe(null)
})
it('defaults device_type to feed when invalid', () => {
const out = validateDeviceBody({ lat: 0, lng: 0, device_type: 'invalid' })
expect(out.device_type).toBe('feed')
})
it('defaults device_type to feed when not a string', () => {
const out = validateDeviceBody({ lat: 0, lng: 0, device_type: 99 })
expect(out.device_type).toBe('feed')
})
it('throws when lat/lng missing or non-finite', () => {
expect(() => validateDeviceBody({})).toThrow()
expect(() => validateDeviceBody({ lat: 0, lng: Number.NaN })).toThrow()
})
it('accepts vendor string and config object', () => {
const out = validateDeviceBody({
name: 'Cam',
lat: 0,
lng: 0,
vendor: ' Acme Corp ',
config: { resolution: '1080p' },
})
expect(out.vendor).toBe('Acme Corp')
expect(out.config).toBe('{"resolution":"1080p"}')
})
})
describe('constants', () => {
it('DEVICE_TYPES includes expected types', () => {
expect(DEVICE_TYPES).toContain('alpr')
expect(DEVICE_TYPES).toContain('feed')
expect(DEVICE_TYPES).toContain('traffic')
expect(DEVICE_TYPES).toContain('ip')
expect(DEVICE_TYPES).toContain('drone')
})
it('SOURCE_TYPES includes mjpeg and hls', () => {
expect(SOURCE_TYPES).toEqual(['mjpeg', 'hls'])
})
})
})

119
test/unit/feedUtils.spec.js Normal file
View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { isValidFeed, getValidFeeds, sanitizeStreamUrl, sanitizeFeedForResponse } from '../../server/utils/feedUtils.js'
describe('feedUtils', () => {
describe('isValidFeed', () => {
it('returns true for valid feed', () => {
expect(isValidFeed({
id: '1',
name: 'Cam',
lat: 37.7,
lng: -122.4,
})).toBe(true)
})
it('returns false for null', () => {
expect(isValidFeed(null)).toBe(false)
})
it('returns false for missing id', () => {
expect(isValidFeed({ name: 'x', lat: 0, lng: 0 })).toBe(false)
})
it('returns false for wrong lat type', () => {
expect(isValidFeed({ id: '1', name: 'x', lat: '37', lng: -122 })).toBe(false)
})
})
describe('getValidFeeds', () => {
it('returns only valid feeds', () => {
const list = [
{ id: 'a', name: 'A', lat: 1, lng: 2 },
null,
{ id: 'b', name: 'B', lat: 3, lng: 4 },
]
expect(getValidFeeds(list)).toHaveLength(2)
})
it('returns empty array for non-array', () => {
expect(getValidFeeds(null)).toEqual([])
expect(getValidFeeds({})).toEqual([])
})
})
describe('sanitizeStreamUrl', () => {
it('allows http and https', () => {
expect(sanitizeStreamUrl('https://example.com/stream')).toBe('https://example.com/stream')
expect(sanitizeStreamUrl('http://example.com/stream')).toBe('http://example.com/stream')
})
it('returns empty for javascript:, data:, and other schemes', () => {
expect(sanitizeStreamUrl('javascript:alert(1)')).toBe('')
expect(sanitizeStreamUrl('data:text/html,<script>')).toBe('')
expect(sanitizeStreamUrl('file:///etc/passwd')).toBe('')
})
it('returns empty for non-strings or empty', () => {
expect(sanitizeStreamUrl('')).toBe('')
expect(sanitizeStreamUrl(' ')).toBe('')
expect(sanitizeStreamUrl(null)).toBe('')
expect(sanitizeStreamUrl(123)).toBe('')
})
})
describe('sanitizeFeedForResponse', () => {
it('returns safe shape with sanitized streamUrl and sourceType', () => {
const feed = {
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
}
const out = sanitizeFeedForResponse(feed)
expect(out).toEqual({
id: 'f1',
name: 'Cam',
lat: 37,
lng: -122,
streamUrl: 'https://safe.com/s',
sourceType: 'mjpeg',
})
})
it('strips dangerous streamUrl and normalizes sourceType', () => {
const feed = {
id: 'f2',
name: 'Bad',
lat: 0,
lng: 0,
streamUrl: 'javascript:alert(1)',
sourceType: 'hls',
}
const out = sanitizeFeedForResponse(feed)
expect(out.streamUrl).toBe('')
expect(out.sourceType).toBe('hls')
})
it('includes description only when string', () => {
const withDesc = sanitizeFeedForResponse({
id: 'a',
name: 'n',
lat: 0,
lng: 0,
description: 'A camera',
})
expect(withDesc.description).toBe('A camera')
const noDesc = sanitizeFeedForResponse({
id: 'b',
name: 'n',
lat: 0,
lng: 0,
description: 123,
})
expect(noDesc).not.toHaveProperty('description')
})
})
})

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
createSession,
getLiveSession,
updateLiveSession,
deleteLiveSession,
getActiveSessions,
getActiveSessionByUserId,
clearSessions,
} from '../../../server/utils/liveSessions.js'
describe('liveSessions', () => {
let sessionId
beforeEach(() => {
clearSessions()
sessionId = createSession('test-user', 'Test Session').id
})
it('creates a session with WebRTC fields', () => {
const session = getLiveSession(sessionId)
expect(session).toBeDefined()
expect(session.id).toBe(sessionId)
expect(session.userId).toBe('test-user')
expect(session.label).toBe('Test Session')
expect(session.routerId).toBeNull()
expect(session.producerId).toBeNull()
expect(session.transportId).toBeNull()
})
it('updates location', () => {
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' })
const session = getLiveSession(sessionId)
expect(session.routerId).toBe('router-1')
expect(session.producerId).toBe('producer-1')
expect(session.transportId).toBe('transport-1')
})
it('returns hasStream instead of hasSnapshot', async () => {
updateLiveSession(sessionId, { producerId: 'producer-1' })
const active = await getActiveSessions()
const session = active.find(s => s.id === sessionId)
expect(session).toBeDefined()
expect(session.hasStream).toBe(true)
})
it('returns hasStream false when no producer', async () => {
const active = await getActiveSessions()
const session = active.find(s => s.id === sessionId)
expect(session).toBeDefined()
expect(session.hasStream).toBe(false)
})
it('deletes a session', () => {
deleteLiveSession(sessionId)
const session = getLiveSession(sessionId)
expect(session).toBeUndefined()
})
it('getActiveSessionByUserId returns session for same user when active', () => {
const found = getActiveSessionByUserId('test-user')
expect(found).toBeDefined()
expect(found.id).toBe(sessionId)
})
it('getActiveSessionByUserId returns undefined for unknown user', () => {
const found = getActiveSessionByUserId('other-user')
expect(found).toBeUndefined()
})
it('getActiveSessionByUserId returns undefined for expired session', () => {
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const found = getActiveSessionByUserId('test-user')
expect(found).toBeUndefined()
})
it('getActiveSessions removes expired sessions', async () => {
const session = getLiveSession(sessionId)
session.updatedAt = Date.now() - 120_000
const active = await getActiveSessions()
expect(active.find(s => s.id === sessionId)).toBeUndefined()
expect(getLiveSession(sessionId)).toBeUndefined()
})
})

71
test/unit/logger.spec.js Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
describe('logger', () => {
let fetchMock
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('$fetch', fetchMock)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
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('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('logWarn sends warn level', () => {
logWarn('warn', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'warn', message: 'warn' }),
}))
})
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('logDebug sends debug level', () => {
logDebug('debug', {})
vi.advanceTimersByTime(10)
expect(fetchMock).toHaveBeenCalledWith('/api/log', expect.objectContaining({
body: expect.objectContaining({ level: 'debug', message: 'debug' }),
}))
})
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)
})
})

View File

@@ -0,0 +1,23 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(__dirname, '../..')
describe('map and geolocation config', () => {
it('Permissions-Policy allows geolocation so browser can prompt', () => {
const configPath = resolve(projectRoot, 'nuxt.config.js')
const source = readFileSync(configPath, 'utf-8')
expect(source).toContain('geolocation=(self)')
expect(source).not.toMatch(/Permissions-Policy[^']*geolocation=\s*\(\s*\)/)
})
it('Leaflet marker assets exist in public so /marker-icon*.png are served', () => {
const publicDir = resolve(projectRoot, 'public')
expect(existsSync(resolve(publicDir, 'marker-icon.png'))).toBe(true)
expect(existsSync(resolve(publicDir, 'marker-icon-2x.png'))).toBe(true)
expect(existsSync(resolve(publicDir, 'marker-shadow.png'))).toBe(true)
})
})

138
test/unit/mediasoup.spec.js Normal file
View File

@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createSession, deleteLiveSession } from '../../../server/utils/liveSessions.js'
import { getRouter, createTransport, closeRouter, getTransport, createProducer, getProducer, createConsumer } from '../../../server/utils/mediasoup.js'
describe('Mediasoup', () => {
let sessionId
beforeEach(() => {
sessionId = createSession('test-user', 'Test Session').id
})
afterEach(async () => {
if (sessionId) {
await closeRouter(sessionId)
deleteLiveSession(sessionId)
}
})
it('should create a router for a session', async () => {
const router = await getRouter(sessionId)
expect(router).toBeDefined()
expect(router.id).toBeDefined()
expect(router.rtpCapabilities).toBeDefined()
})
it('should create a transport', async () => {
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true)
expect(transport).toBeDefined()
expect(params.id).toBe(transport.id)
expect(params.iceParameters).toBeDefined()
expect(params.iceCandidates).toBeDefined()
expect(params.dtlsParameters).toBeDefined()
})
it('should create a transport with requestHost IPv4 and return valid params', async () => {
const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, true, '192.168.2.100')
expect(transport).toBeDefined()
expect(params.id).toBe(transport.id)
expect(params.iceParameters).toBeDefined()
expect(params.iceCandidates).toBeDefined()
expect(Array.isArray(params.iceCandidates)).toBe(true)
expect(params.dtlsParameters).toBeDefined()
})
it('should reuse router for same session', async () => {
const router1 = await getRouter(sessionId)
const router2 = await getRouter(sessionId)
expect(router1.id).toBe(router2.id)
})
it('should get transport by ID', async () => {
const router = await getRouter(sessionId)
const { transport } = await createTransport(router, true)
const retrieved = getTransport(transport.id)
expect(retrieved).toBe(transport)
})
it.skip('should create a producer with mock track', async () => {
// Mediasoup produce() requires a real MediaStreamTrack (native addon); plain mocks fail with "invalid kind"
const router = await getRouter(sessionId)
const { transport } = await createTransport(router, true)
const mockTrack = {
id: 'mock-track-id',
kind: 'video',
enabled: true,
readyState: 'live',
}
const producer = await createProducer(transport, mockTrack)
expect(producer).toBeDefined()
expect(producer.id).toBeDefined()
expect(producer.kind).toBe('video')
const retrieved = getProducer(producer.id)
expect(retrieved).toBe(producer)
})
it.skip('should cleanup producer on close', async () => {
// Depends on createProducer which requires real MediaStreamTrack in Node
const router = await getRouter(sessionId)
const { transport } = await createTransport(router, true)
const mockTrack = { id: 'mock-track-id', kind: 'video', enabled: true, readyState: 'live' }
const producer = await createProducer(transport, mockTrack)
const producerId = producer.id
expect(getProducer(producerId)).toBe(producer)
producer.close()
let attempts = 0
while (getProducer(producerId) && attempts < 50) {
await new Promise(resolve => setTimeout(resolve, 10))
attempts++
}
expect(getProducer(producerId) || producer.closed).toBeTruthy()
})
it.skip('should create a consumer', async () => {
// Depends on createProducer which requires real MediaStreamTrack in Node
const router = await getRouter(sessionId)
const { transport } = await createTransport(router, true)
const mockTrack = { id: 'mock-track-id', kind: 'video', enabled: true, readyState: 'live' }
const producer = await createProducer(transport, mockTrack)
const rtpCapabilities = router.rtpCapabilities
const { consumer, params } = await createConsumer(transport, producer, rtpCapabilities)
expect(consumer).toBeDefined()
expect(consumer.id).toBeDefined()
expect(consumer.producerId).toBe(producer.id)
expect(params.id).toBe(consumer.id)
expect(params.producerId).toBe(producer.id)
expect(params.kind).toBeDefined()
expect(params.rtpParameters).toBeDefined()
})
it('should cleanup transport on close', async () => {
const router = await getRouter(sessionId)
const { transport } = await createTransport(router, true)
const transportId = transport.id
expect(getTransport(transportId)).toBe(transport)
// Close transport - cleanup happens via 'close' event handler
transport.close()
// Wait for async cleanup (mediasoup fires 'close' event asynchronously)
// Use a promise that resolves when transport is removed or timeout
let attempts = 0
while (getTransport(transportId) && attempts < 50) {
await new Promise(resolve => setTimeout(resolve, 10))
attempts++
}
// Transport should be removed from Map (or at least closed)
expect(getTransport(transportId) || transport.closed).toBeTruthy()
})
it('should cleanup router on closeRouter', async () => {
await getRouter(sessionId)
await closeRouter(sessionId)
const routerAfter = await getRouter(sessionId)
// New router should have different ID (or same if cached, but old one should be closed)
// This test verifies closeRouter doesn't throw
expect(routerAfter).toBeDefined()
})
})

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
import { migrateFeedsToDevices } from '../../server/utils/migrateFeedsToDevices.js'
describe('migrateFeedsToDevices', () => {
beforeEach(() => {
setDbPathForTest(':memory:')
})
afterEach(() => {
setDbPathForTest(null)
})
it('runs without error when devices table is empty', async () => {
const db = await getDb()
await expect(migrateFeedsToDevices()).resolves.toBeUndefined()
const rows = await db.all('SELECT id FROM devices')
expect(rows.length).toBeGreaterThanOrEqual(0)
})
it('is no-op when devices already has rows', async () => {
const db = await getDb()
await db.run(
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
['existing', 'Existing', 'feed', null, 0, 0, '', 'mjpeg', null],
)
await migrateFeedsToDevices()
const rows = await db.all('SELECT id FROM devices')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('existing')
})
})

125
test/unit/oidc.spec.js Normal file
View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
constantTimeCompare,
validateRedirectPath,
createOidcParams,
getCodeChallenge,
getOidcRedirectUri,
getOidcConfig,
} from '../../server/utils/oidc.js'
describe('oidc', () => {
describe('constantTimeCompare', () => {
it('returns true for equal strings', () => {
expect(constantTimeCompare('abc', 'abc')).toBe(true)
})
it('returns false for different strings', () => {
expect(constantTimeCompare('abc', 'abd')).toBe(false)
})
it('returns false for different length', () => {
expect(constantTimeCompare('ab', 'abc')).toBe(false)
})
it('returns false for non-strings', () => {
expect(constantTimeCompare('a', 1)).toBe(false)
})
})
describe('validateRedirectPath', () => {
it('returns path for valid same-origin path', () => {
expect(validateRedirectPath('/')).toBe('/')
expect(validateRedirectPath('/feeds')).toBe('/feeds')
expect(validateRedirectPath('/feeds?foo=1')).toBe('/feeds?foo=1')
})
it('returns / for path starting with //', () => {
expect(validateRedirectPath('//evil.com')).toBe('/')
})
it('returns / for non-string or empty', () => {
expect(validateRedirectPath('')).toBe('/')
expect(validateRedirectPath(null)).toBe('/')
})
it('returns / for path containing //', () => {
expect(validateRedirectPath('/foo//bar')).toBe('/')
})
})
describe('createOidcParams', () => {
it('returns state, nonce, and codeVerifier', () => {
const p = createOidcParams()
expect(p).toHaveProperty('state')
expect(p).toHaveProperty('nonce')
expect(p).toHaveProperty('codeVerifier')
expect(typeof p.state).toBe('string')
expect(typeof p.nonce).toBe('string')
expect(typeof p.codeVerifier).toBe('string')
})
})
describe('getCodeChallenge', () => {
it('returns a string for a verifier', async () => {
const p = createOidcParams()
const challenge = await getCodeChallenge(p.codeVerifier)
expect(typeof challenge).toBe('string')
expect(challenge.length).toBeGreaterThan(0)
})
})
describe('getOidcRedirectUri', () => {
const origEnv = process.env
afterEach(() => {
process.env = origEnv
})
it('returns a URL ending with callback path when env is default', () => {
delete process.env.OIDC_REDIRECT_URI
delete process.env.OPENID_REDIRECT_URI
delete process.env.NUXT_APP_URL
delete process.env.APP_URL
const uri = getOidcRedirectUri()
expect(uri).toMatch(/\/api\/auth\/oidc\/callback$/)
})
it('returns explicit OIDC_REDIRECT_URI when set', () => {
process.env.OIDC_REDIRECT_URI = ' https://app.example.com/oidc/cb '
const uri = getOidcRedirectUri()
expect(uri).toBe('https://app.example.com/oidc/cb')
})
it('returns URL from NUXT_APP_URL when set and no explicit redirect', () => {
delete process.env.OIDC_REDIRECT_URI
delete process.env.OPENID_REDIRECT_URI
process.env.NUXT_APP_URL = 'https://myapp.example.com/'
const uri = getOidcRedirectUri()
expect(uri).toBe('https://myapp.example.com/api/auth/oidc/callback')
})
it('returns URL from APP_URL when set and no NUXT_APP_URL', () => {
delete process.env.OIDC_REDIRECT_URI
delete process.env.OPENID_REDIRECT_URI
delete process.env.NUXT_APP_URL
process.env.APP_URL = 'https://app.example.com'
const uri = getOidcRedirectUri()
expect(uri).toBe('https://app.example.com/api/auth/oidc/callback')
})
})
describe('getOidcConfig', () => {
const origEnv = process.env
beforeEach(() => {
process.env = { ...origEnv }
})
afterEach(() => {
process.env = origEnv
})
it('returns null when OIDC env vars missing', async () => {
delete process.env.OIDC_ISSUER
delete process.env.OIDC_CLIENT_ID
delete process.env.OIDC_CLIENT_SECRET
const config = await getOidcConfig()
expect(config).toBeNull()
})
})
})

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { hashPassword, verifyPassword } from '../../server/utils/password.js'
describe('password', () => {
it('hashes and verifies', () => {
const password = 'secret123'
const stored = hashPassword(password)
expect(stored).toContain(':')
expect(verifyPassword(password, stored)).toBe(true)
})
it('rejects wrong password', () => {
const stored = hashPassword('right')
expect(verifyPassword('wrong', stored)).toBe(false)
})
it('rejects invalid stored format', () => {
expect(verifyPassword('a', '')).toBe(false)
expect(verifyPassword('a', 'nocolon')).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(__dirname, '../..')
const serverDir = resolve(projectRoot, 'server')
/** Collects all .js file paths under dir (recursive). */
function listJsFiles(dir, base = dir) {
const entries = readdirSync(dir, { withFileTypes: true })
const files = []
for (const e of entries) {
const full = resolve(dir, e.name)
if (e.isDirectory()) {
files.push(...listJsFiles(full, base))
}
else if (e.isFile() && e.name.endsWith('.js')) {
files.push(relative(base, full))
}
}
return files
}
/** Extracts relative import/require paths from file content (no node_modules). */
function getRelativeImports(content) {
const paths = []
const fromRegex = /from\s+['"]([^'"]+)['"]/g
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
for (const re of [fromRegex, requireRegex]) {
let m
while ((m = re.exec(content)) !== null) {
const p = m[1]
if (p.startsWith('.')) paths.push(p)
}
}
return paths
}
describe('server imports', () => {
it('only import from within server/ (no shared/ or other outside paths)', () => {
const violations = []
const files = listJsFiles(serverDir).map(f => resolve(serverDir, f))
for (const filePath of files) {
const content = readFileSync(filePath, 'utf-8')
const fileDir = dirname(filePath)
for (const importPath of getRelativeImports(content)) {
const resolved = resolve(fileDir, importPath)
const relToServer = relative(serverDir, resolved)
if (relToServer.startsWith('..') || relToServer.startsWith('/')) {
violations.push({
file: relative(projectRoot, filePath),
import: importPath,
resolved: relative(projectRoot, resolved),
})
}
}
}
expect(
violations,
violations.length
? `Server files must not import from outside server/. Violations:\n${violations
.map(v => ` ${v.file}${v.import} (resolves to ${v.resolved})`)
.join('\n')}`
: undefined,
).toHaveLength(0)
})
})

39
test/unit/session.spec.js Normal file
View File

@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getSessionMaxAgeDays } from '../../server/utils/session.js'
describe('session', () => {
const origEnv = process.env
beforeEach(() => {
process.env = { ...origEnv }
})
afterEach(() => {
process.env = origEnv
})
it('returns default 7 days when SESSION_MAX_AGE_DAYS not set', () => {
delete process.env.SESSION_MAX_AGE_DAYS
expect(getSessionMaxAgeDays()).toBe(7)
})
it('returns default when SESSION_MAX_AGE_DAYS is NaN', () => {
process.env.SESSION_MAX_AGE_DAYS = 'invalid'
expect(getSessionMaxAgeDays()).toBe(7)
})
it('clamps to MIN_DAYS (1) when value below', () => {
process.env.SESSION_MAX_AGE_DAYS = '0'
expect(getSessionMaxAgeDays()).toBe(1)
})
it('clamps to MAX_DAYS (365) when value above', () => {
process.env.SESSION_MAX_AGE_DAYS = '400'
expect(getSessionMaxAgeDays()).toBe(365)
})
it('returns parsed value when within range', () => {
process.env.SESSION_MAX_AGE_DAYS = '14'
expect(getSessionMaxAgeDays()).toBe(14)
})
})

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createSession, clearSessions } from '../../server/utils/liveSessions.js'
import { handleWebSocketMessage } from '../../server/utils/webrtcSignaling.js'
vi.mock('../../server/utils/mediasoup.js', () => {
const mockConnect = vi.fn().mockResolvedValue(undefined)
const mockRouter = { id: 'mock-router', rtpCapabilities: { codecs: [] } }
const mockTransport = { id: 'mock-transport', connect: mockConnect }
return {
getRouter: vi.fn().mockResolvedValue(mockRouter),
createTransport: vi.fn().mockResolvedValue({
transport: mockTransport,
params: { id: 'mock-transport', iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
}),
getTransport: vi.fn().mockReturnValue(mockTransport),
closeRouter: vi.fn().mockResolvedValue(undefined),
getProducer: vi.fn().mockReturnValue(null),
}
})
describe('webrtcSignaling', () => {
let sessionId
const userId = 'test-user'
beforeEach(() => {
clearSessions()
sessionId = createSession(userId, 'Test').id
})
it('returns error when session not found', async () => {
const res = await handleWebSocketMessage(userId, 'non-existent-id', 'get-router-rtp-capabilities', {})
expect(res).toEqual({ error: 'Session not found' })
})
it('returns Forbidden when userId does not match session', async () => {
const res = await handleWebSocketMessage('other-user', sessionId, 'create-transport', {})
expect(res).toEqual({ error: 'Forbidden' })
})
it('returns error for unknown message type', async () => {
const res = await handleWebSocketMessage(userId, sessionId, 'unknown-type', {})
expect(res).toEqual({ error: 'Unknown message type: unknown-type' })
})
it('returns transportId and dtlsParameters required for connect-transport', async () => {
const res = await handleWebSocketMessage(userId, sessionId, 'connect-transport', {})
expect(res?.error).toContain('transportId')
})
it('get-router-rtp-capabilities returns router RTP capabilities', async () => {
const res = await handleWebSocketMessage(userId, sessionId, 'get-router-rtp-capabilities', {})
expect(res?.type).toBe('router-rtp-capabilities')
expect(res?.data).toEqual({ codecs: [] })
})
it('create-transport returns transport params', async () => {
const res = await handleWebSocketMessage(userId, sessionId, 'create-transport', {})
expect(res?.type).toBe('transport-created')
expect(res?.data).toBeDefined()
})
it('connect-transport connects with valid params', async () => {
await handleWebSocketMessage(userId, sessionId, 'create-transport', {})
const res = await handleWebSocketMessage(userId, sessionId, 'connect-transport', {
transportId: 'mock-transport',
dtlsParameters: { role: 'client', fingerprints: [] },
})
expect(res?.type).toBe('transport-connected')
expect(res?.data?.connected).toBe(true)
})
})