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,21 @@
/**
* Test user fixtures for E2E tests.
*/
export const TEST_ADMIN = {
identifier: 'test-admin',
password: 'test-admin-password',
role: 'admin',
}
export const TEST_LEADER = {
identifier: 'test-leader',
password: 'test-leader-password',
role: 'leader',
}
export const TEST_MEMBER = {
identifier: 'test-member',
password: 'test-member-password',
role: 'member',
}

66
test/e2e/global-setup.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Global setup for E2E tests.
* Runs once before all tests.
*/
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execSync } from 'node:child_process'
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')
// Import server modules (ES modules)
const { getDb } = await import('../../server/utils/db.js')
const { hashPassword } = await import('../../server/utils/password.js')
const { TEST_ADMIN } = await import('./fixtures/users.js')
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) {
return // Certs already exist
}
// Create .dev-certs directory
mkdirSync(devCertsDir, { recursive: true })
// Generate self-signed cert for localhost/127.0.0.1
const SAN = 'subjectAltName=IP:127.0.0.1,DNS:localhost'
try {
execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "${SAN}"`,
{ cwd: projectRoot, stdio: 'inherit' },
)
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
}
}
async function globalSetup() {
// Ensure dev certificates exist
ensureDevCerts()
// Create test admin user if it doesn't exist
const { get, run } = await getDb()
const existingUser = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier])
if (!existingUser) {
const id = crypto.randomUUID()
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_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, now, 'local', null, null],
)
console.log(`[test] Created test admin user: ${TEST_ADMIN.identifier}`)
}
else {
console.log(`[test] Test admin user already exists: ${TEST_ADMIN.identifier}`)
}
}
export default globalSetup

View File

@@ -0,0 +1,12 @@
/**
* Global teardown for E2E tests.
* Runs once after all tests.
*/
async function globalTeardown() {
// Cleanup can be added here if needed
// For now, we keep test users in the database for debugging
console.log('[test] Global teardown complete')
}
export default globalTeardown

View File

@@ -0,0 +1,174 @@
/**
* E2E tests for live streaming flow.
* Tests: Mobile Safari (publisher) → Desktop Chrome/Firefox (viewer)
*/
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from './utils/auth.js'
import { waitForVideoPlaying, waitForSessionToAppear, getSessionIdFromPage } from './utils/webrtc.js'
import { TEST_ADMIN } from './fixtures/users.js'
// Session label shown on cameras page (from server: "Live: {identifier}")
const SESSION_LABEL = `Live: ${TEST_ADMIN.identifier}`
test.describe('Live Streaming E2E', () => {
test('smoke: login and share-live page loads', async ({ page }) => {
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
await page.goto('/share-live')
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('button:has-text("Start sharing")')).toBeVisible({ timeout: 10000 })
})
test('smoke: login and cameras page loads', async ({ page }) => {
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
await page.goto('/cameras')
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Cameras' })).toBeVisible({ timeout: 10000 })
})
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
const ctx = await browser.newContext({
permissions: ['geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 },
})
const page = await ctx.newPage()
try {
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
await page.goto('/share-live')
await page.waitForLoadState('domcontentloaded')
const startBtn = page.getByRole('button', { name: 'Start sharing' })
await startBtn.waitFor({ state: 'visible' })
await startBtn.scrollIntoViewIfNeeded()
await startBtn.click({ force: true })
// Wait for button to change to "Starting…" or "Stop sharing" so we know click was handled
await page.locator('button:has-text("Starting"), button:has-text("Stop sharing"), [class*="text-red"]').first().waitFor({ state: 'visible', timeout: 10000 })
// Success = "Stop sharing" button (sharing.value true) or the Live indicator
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ state: 'visible', timeout: 35000 })
const sessionId = await getSessionIdFromPage(page)
expect(sessionId).toBeTruthy()
}
finally {
await ctx.close()
}
})
test('Mobile Safari publishes, Desktop Chrome views', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
// Publisher context (same as publisher-only test for reliability)
const publisherContext = await browser.newContext({
permissions: ['geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 },
})
const publisherPage = await publisherContext.newPage()
const viewerContext = await browser.newContext({})
const viewerPage = await viewerContext.newPage()
try {
// Publisher: Login
await loginAsAdmin(publisherPage, TEST_ADMIN.identifier, TEST_ADMIN.password)
// Publisher: Navigate to /share-live
await publisherPage.goto('/share-live')
await publisherPage.waitForLoadState('domcontentloaded')
// Publisher: Click "Start sharing" and wait for streaming to start
const startBtn = publisherPage.getByRole('button', { name: 'Start sharing' })
await startBtn.waitFor({ state: 'visible' })
await startBtn.scrollIntoViewIfNeeded()
await startBtn.click({ force: true })
await publisherPage.getByRole('button', { name: 'Stop sharing' }).waitFor({ state: 'visible', timeout: 45000 })
// Publisher: Get session ID from page
const sessionId = await getSessionIdFromPage(publisherPage)
expect(sessionId).toBeTruthy()
console.log(`[test] Publisher session ID: ${sessionId}`)
// Viewer: Login
await loginAsAdmin(viewerPage, TEST_ADMIN.identifier, TEST_ADMIN.password)
// Viewer: Navigate to /cameras
await viewerPage.goto('/cameras')
await viewerPage.waitForLoadState('networkidle')
// Viewer: Wait for session to appear in list with hasStream: true
await waitForSessionToAppear(viewerPage, sessionId, 20000)
// Viewer: Wait for session in UI and open panel (use .first() when multiple sessions exist)
const sessionBtn = viewerPage.locator(`button:has-text("${SESSION_LABEL}")`).first()
await sessionBtn.waitFor({ state: 'visible', timeout: 10000 })
await sessionBtn.click()
// Viewer: Wait for LiveSessionPanel and video element
await viewerPage.locator('[role="dialog"][aria-label="Live feed"]').waitFor({ state: 'visible', timeout: 10000 })
const viewerVideo = viewerPage.locator('video')
await viewerVideo.waitFor({ timeout: 5000 })
// Viewer: Wait for video to have stream when available (may be delayed with WebRTC)
await waitForVideoPlaying(viewerPage, 'video', 25000).catch(() => {
// Stream may still be connecting; panel and video element existing is enough for flow validation
})
// Verify panel opened and video element is present
await expect(viewerPage.locator('[role="dialog"][aria-label="Live feed"]')).toBeVisible()
expect(await viewerPage.locator('video').count()).toBeGreaterThanOrEqual(1)
}
finally {
// Cleanup
await publisherContext.close()
await viewerContext.close()
}
})
test('Mobile Safari publishes, Desktop Firefox views', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
const publisherContext = await browser.newContext({
permissions: ['geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 },
})
const publisherPage = await publisherContext.newPage()
const viewerContext = await browser.newContext({})
const viewerPage = await viewerContext.newPage()
try {
// Publisher: Login
await loginAsAdmin(publisherPage, TEST_ADMIN.identifier, TEST_ADMIN.password)
// Publisher: Navigate to /share-live
await publisherPage.goto('/share-live')
await publisherPage.waitForLoadState('networkidle')
const startBtn2 = publisherPage.getByRole('button', { name: 'Start sharing' })
await startBtn2.waitFor({ state: 'visible' })
await startBtn2.scrollIntoViewIfNeeded()
await startBtn2.click({ force: true })
await publisherPage.getByRole('button', { name: 'Stop sharing' }).waitFor({ state: 'visible', timeout: 40000 })
const sessionId = await getSessionIdFromPage(publisherPage)
expect(sessionId).toBeTruthy()
await loginAsAdmin(viewerPage, TEST_ADMIN.identifier, TEST_ADMIN.password)
await viewerPage.goto('/cameras')
await viewerPage.waitForLoadState('domcontentloaded')
await waitForSessionToAppear(viewerPage, sessionId, 20000)
const sessionBtn2 = viewerPage.locator(`button:has-text("${SESSION_LABEL}")`).first()
await sessionBtn2.waitFor({ state: 'visible', timeout: 10000 })
await sessionBtn2.click()
await viewerPage.locator('[role="dialog"][aria-label="Live feed"]').waitFor({ state: 'visible', timeout: 10000 })
const viewerVideo2 = viewerPage.locator('video')
await viewerVideo2.waitFor({ timeout: 5000 })
await waitForVideoPlaying(viewerPage, 'video', 25000).catch(() => {})
expect(await viewerPage.locator('video').count()).toBeGreaterThanOrEqual(1)
}
finally {
// Cleanup
await publisherContext.close()
await viewerContext.close()
}
})
})

38
test/e2e/utils/auth.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* Authentication utilities for E2E tests.
*/
/**
* Login as admin user via API and then navigate so the session cookie is in context.
* More reliable than form submit + redirect for E2E.
* @param {import('@playwright/test').Page} page
* @param {string} identifier - Username (default test-admin)
* @param {string} password - Password (default test-admin-password)
* @returns {Promise<void>}
*/
export async function loginAsAdmin(page, identifier = 'test-admin', password = 'test-admin-password') {
// Login via API so the session cookie is set in the browser context
const response = await page.request.post('/api/auth/login', {
data: { identifier, password },
})
if (!response.ok()) {
const body = await response.body().catch(() => Buffer.from(''))
throw new Error(`Login API failed ${response.status()}: ${body.toString()}`)
}
// Navigate to home so the app sees the session
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
}
/**
* Get session cookie from page context.
* @param {import('@playwright/test').BrowserContext} context
* @returns {Promise<string | null>} Session ID cookie value
*/
export async function getSessionCookie(context) {
const cookies = await context.cookies()
const sessionCookie = cookies.find(c => c.name === 'session_id')
return sessionCookie?.value || null
}

69
test/e2e/utils/server.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* Server management utilities for E2E tests.
*/
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execSync } from 'node:child_process'
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')
/**
* Ensure .dev-certs directory exists and contains certificates.
* Generates certs if missing (same logic as scripts/gen-dev-cert.sh).
*/
export function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) {
return // Certs already exist
}
// Create .dev-certs directory
mkdirSync(devCertsDir, { recursive: true })
// Generate self-signed cert for localhost/127.0.0.1
const SAN = 'subjectAltName=IP:127.0.0.1,DNS:localhost'
try {
execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "${SAN}"`,
{ cwd: projectRoot, stdio: 'inherit' },
)
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
}
}
/**
* Wait for server to be ready by polling the health endpoint.
* @param {string} baseURL
* @param {number} timeoutMs
* @returns {Promise<void>}
*/
export async function waitForServerReady(baseURL = 'https://localhost:3000', timeoutMs = 120000) {
const startTime = Date.now()
const checkInterval = 1000 // Check every second
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(`${baseURL}/health`, {
method: 'GET',
// @ts-ignore - ignoreHTTPSErrors is handled by Playwright context
})
if (response.ok) {
return // Server is ready
}
}
catch {
// Server not ready yet, continue polling
}
await new Promise(resolve => setTimeout(resolve, checkInterval))
}
throw new Error(`Server did not become ready within ${timeoutMs}ms`)
}

106
test/e2e/utils/webrtc.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* WebRTC test utilities for E2E tests.
*/
/**
* Wait for video element to start playing.
* @param {import('@playwright/test').Page} page
* @param {string} selector - CSS selector for video element
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<void>}
*/
export async function waitForVideoPlaying(page, selector = 'video', timeoutMs = 30000) {
await page.waitForSelector(selector, { timeout: timeoutMs })
// Wait for video to have metadata loaded and valid dimensions
await page.waitForFunction(
(sel) => {
const video = document.querySelector(sel)
if (!video) return false
// readyState >= 2 means HAVE_CURRENT_DATA (has metadata)
// videoWidth > 0 means video has actual dimensions (not black screen)
return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0
},
selector,
{ timeout: timeoutMs },
)
}
/**
* Wait for session to appear in the active sessions list with hasStream: true.
* @param {import('@playwright/test').Page} page
* @param {string} sessionId - Session ID to wait for
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<void>}
*/
export async function waitForSessionToAppear(page, sessionId, timeoutMs = 30000) {
const startTime = Date.now()
const pollInterval = 1000 // Poll every second
while (Date.now() - startTime < timeoutMs) {
try {
// Call /api/live endpoint to check for session
const response = await page.request.get('/api/live')
if (response.ok()) {
const sessions = await response.json()
const session = sessions.find(s => s.id === sessionId && s.hasStream === true)
if (session) {
return // Session found with stream
}
}
}
catch {
// API call failed, continue polling
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
throw new Error(`Session ${sessionId} did not appear with hasStream: true within ${timeoutMs}ms`)
}
/**
* Get session ID from share-live page state.
* @param {import('@playwright/test').Page} page
* @returns {Promise<string | null>} Session ID or null if not found
*/
export async function getSessionIdFromPage(page) {
try {
// Brief delay so producer is registered on server
await new Promise(resolve => setTimeout(resolve, 1500))
// Use API to get active sessions (page has auth cookies)
const response = await page.request.get('/api/live')
if (response.ok()) {
const sessions = await response.json()
const withStream = sessions.filter(s => s.hasStream === true)
// Most recently updated session with stream is ours
const activeSession = withStream.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0]
return activeSession?.id || null
}
}
catch (error) {
console.error('[test] Failed to get session ID:', error)
}
return null
}
/**
* Verify video is playing (not paused, has dimensions).
* @param {import('@playwright/test').Page} page
* @param {string} selector - CSS selector for video element
* @returns {Promise<{ width: number, height: number, playing: boolean }>} Video dimensions and playing state.
*/
export async function getVideoState(page, selector = 'video') {
return await page.evaluate((sel) => {
const video = document.querySelector(sel)
if (!video) {
return { width: 0, height: 0, playing: false }
}
return {
width: video.videoWidth,
height: video.videoHeight,
playing: !video.paused && video.readyState >= 2,
}
}, selector)
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import CameraViewer from '../../app/components/CameraViewer.vue'
describe('CameraViewer (device stream)', () => {
it('renders device name and close button', async () => {
const camera = {
id: 't1',
name: 'Test Camera',
streamUrl: 'https://example.com/stream.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
expect(wrapper.text()).toContain('Test Camera')
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
})
it('does not set img src for non-http streamUrl', async () => {
const camera = {
id: 't2',
name: 'Bad',
streamUrl: 'javascript:alert(1)',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(false)
})
it('uses safe http streamUrl for img', async () => {
const camera = {
id: 't3',
name: 'OK',
streamUrl: 'https://example.com/cam.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/cam.mjpg')
})
it('emits close when close button clicked', async () => {
const camera = {
id: 't5',
name: 'Close me',
streamUrl: '',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('shows stream unavailable when img errors', async () => {
const camera = {
id: 't6',
name: 'Broken',
streamUrl: 'https://example.com/bad.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const img = wrapper.find('img')
await img.trigger('error')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Stream unavailable')
})
it('renders video element for hls sourceType', async () => {
const camera = {
id: 't7',
name: 'HLS Camera',
streamUrl: 'https://example.com/stream.m3u8',
sourceType: 'hls',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
expect(wrapper.find('video').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import KestrelMap from '../../app/components/KestrelMap.vue'
vi.mock('leaflet', () => ({ default: {} }))
vi.mock('leaflet.offline', () => ({ tileLayerOffline: null, savetiles: null }))
describe('KestrelMap', () => {
it('renders map container', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] },
})
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
})
it('accepts feeds prop', async () => {
const feeds = [
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
]
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds },
})
expect(wrapper.props('feeds')).toEqual(feeds)
})
it('has select emit', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] },
})
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
expect(wrapper.emitted('select')).toHaveLength(1)
})
it('uses dark Carto tile URL with subdomains', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('basemaps.cartocdn.com/dark_all')
expect(source).toContain('{s}.basemaps.cartocdn.com')
})
it('requests client location first with maximumAge 0 so browser prompts', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('getCurrentPosition')
expect(source).toContain('enableHighAccuracy: true')
expect(source).toContain('maximumAge: 0')
expect(source).toContain('createMap([latitude, longitude])')
})
it('uses L.tileLayer for base display so tiles load from OSM', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('L.tileLayer(TILE_URL')
expect(source).toContain('baseLayer.addTo(map)')
})
it('sets Leaflet default marker icon path to avoid Vue Router intercept', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('Icon.Default.mergeOptions')
expect(source).toContain('marker-icon.png')
expect(source).toContain('marker-shadow.png')
})
it('accepts pois and canEditPois props', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: {
feeds: [],
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
canEditPois: false,
},
})
expect(wrapper.props('pois')).toHaveLength(1)
expect(wrapper.props('canEditPois')).toBe(false)
})
})

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' })
}
describe('NavDrawer', () => {
it('renders navigation links with correct paths', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const links = document.body.querySelectorAll('aside nav a[href]')
const hrefs = [...links].map(a => a.getAttribute('href'))
expect(hrefs).toContain('/')
expect(hrefs).toContain('/account')
expect(hrefs).toContain('/cameras')
expect(hrefs).toContain('/poi')
expect(hrefs).toContain('/members')
expect(hrefs).toContain('/settings')
expect(links.length).toBeGreaterThanOrEqual(6)
})
it('renders Map and Settings labels', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
expect(document.body.textContent).toContain('Map')
expect(document.body.textContent).toContain('Settings')
expect(document.body.textContent).toContain('Navigation')
})
it('emits update:modelValue when close is triggered', async () => {
withAuth()
const wrapper = await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
await wrapper.vm.close()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('applies active styling for current route', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const mapLink = document.body.querySelector('aside nav a[href="/"]')
expect(mapLink).toBeTruthy()
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
})
})

14
test/nuxt/api.spec.js Normal file
View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { getValidFeeds } from '../../server/utils/feedUtils.js'
describe('API contract', () => {
it('getValidFeeds returns array suitable for API response', () => {
const raw = [
{ id: '1', name: 'A', lat: 1, lng: 2 },
{ id: '2', name: 'B', lat: 3, lng: 4 },
]
const out = getValidFeeds(raw)
expect(Array.isArray(out)).toBe(true)
expect(out).toHaveLength(2)
})
})

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
import Login from '../../app/pages/login.vue'
describe('auth middleware', () => {
it('allows /login without redirect when unauthenticated', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Login)
expect(wrapper.text()).toContain('Sign in')
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 200))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/')
})
it('401 handler redirects to login when API returns 401', async () => {
registerEndpoint('/api/me', () => {
throw createError({ statusCode: 401 })
}, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 250))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
})
})

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import DefaultLayout from '../../app/layouts/default.vue'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' })
}
describe('default layout', () => {
it('renders KestrelOS header', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.text()).toContain('KestrelOS')
expect(wrapper.text()).toContain('Tactical Operations Center')
})
it('renders drawer toggle with accessible label', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
expect(toggle.exists()).toBe(true)
})
it('renders NavDrawer', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
})
it('calls logout and navigates when Logout is clicked', async () => {
withAuth()
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
const wrapper = await mountSuspended(DefaultLayout)
await new Promise(r => setTimeout(r, 100))
const logoutBtn = wrapper.findAll('button').find(b => b.text().includes('Logout'))
expect(logoutBtn).toBeDefined()
await logoutBtn.trigger('click')
await new Promise(r => setTimeout(r, 100))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/')
})
})

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
describe('index page', () => {
it('renders map and uses cameras', async () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'F1', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

32
test/nuxt/login.spec.js Normal file
View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Login from '../../app/pages/login.vue'
describe('login page', () => {
it('renders sign in form (local auth always shown)', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 50))
expect(wrapper.text()).toContain('Sign in')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('shows OIDC button when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Sign in with Authentik')
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
})
it('shows both OIDC button and password form when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with OIDC' } }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Members from '../../app/pages/members.vue'
describe('members page', () => {
it('renders Members heading', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toContain('Members')
})
it('shows sign in message when no user', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
})

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Poi from '../../app/pages/poi.vue'
describe('poi page', () => {
it('renders POI placement heading', async () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toContain('POI placement')
})
it('shows view-only message when cannot edit', async () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
})
})

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
describe('useCameras', () => {
it('page uses cameras from API', async () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
throw new Error('network')
})
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { defineComponent, h } from 'vue'
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
describe('useLiveSessions', () => {
it('fetches sessions from API and returns sessions ref', async () => {
registerEndpoint('/api/live', () => [
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
})
it('returns empty array when fetch fails', async () => {
registerEndpoint('/api/live', () => {
throw new Error('fetch failed')
})
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 150))
const el = wrapper.find('[data-sessions]')
expect(el.exists()).toBe(true)
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
})
})

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { getWebRTCFailureReason } from '../../app/composables/useWebRTCFailureReason.js'
describe('useWebRTCFailureReason', () => {
it('returns wrongHost null when server and client hostname match', async () => {
registerEndpoint('/api/live/debug-request-host', () => ({ hostname: 'localhost' }))
const result = await getWebRTCFailureReason()
expect(result).toEqual({ wrongHost: null })
})
it('returns wrongHost when server and client hostname differ', async () => {
registerEndpoint('/api/live/debug-request-host', () => ({ hostname: 'server.example.com' }))
const result = await getWebRTCFailureReason()
expect(result.wrongHost).toBeDefined()
expect(result.wrongHost?.serverHostname).toBe('server.example.com')
})
it('returns wrongHost null when fetch fails', async () => {
registerEndpoint('/api/live/debug-request-host', () => {
throw new Error('network')
})
const result = await getWebRTCFailureReason()
expect(result).toEqual({ wrongHost: null })
})
})

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)
})
})