initial commit
This commit is contained in:
43
test/unit/authConfig.spec.js
Normal file
43
test/unit/authConfig.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
61
test/unit/authHelpers.spec.js
Normal file
61
test/unit/authHelpers.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
42
test/unit/authSkipPaths.spec.js
Normal file
42
test/unit/authSkipPaths.spec.js
Normal 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
56
test/unit/db.spec.js
Normal 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' })
|
||||
})
|
||||
})
|
||||
171
test/unit/deviceUtils.spec.js
Normal file
171
test/unit/deviceUtils.spec.js
Normal 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
119
test/unit/feedUtils.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
92
test/unit/liveSessions.spec.js
Normal file
92
test/unit/liveSessions.spec.js
Normal 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
71
test/unit/logger.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
23
test/unit/map-and-geolocation.spec.js
Normal file
23
test/unit/map-and-geolocation.spec.js
Normal 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
138
test/unit/mediasoup.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
32
test/unit/migrateFeedsToDevices.spec.js
Normal file
32
test/unit/migrateFeedsToDevices.spec.js
Normal 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
125
test/unit/oidc.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
21
test/unit/password.spec.js
Normal file
21
test/unit/password.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
71
test/unit/server-imports.spec.js
Normal file
71
test/unit/server-imports.spec.js
Normal 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
39
test/unit/session.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
71
test/unit/webrtcSignaling.spec.js
Normal file
71
test/unit/webrtcSignaling.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user