This commit is contained in:
@@ -253,7 +253,7 @@ async function setupWebRTC() {
|
|||||||
hasStream.value = false
|
hasStream.value = false
|
||||||
})
|
})
|
||||||
videoRef.value.addEventListener('error', () => {
|
videoRef.value.addEventListener('error', () => {
|
||||||
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
|
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ openssl req -x509 -newkey rsa:2048 -keyout "$KEY" -out "$CERT" -days 365 -nodes
|
|||||||
echo "Created $KEY and $CERT"
|
echo "Created $KEY and $CERT"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next: run npm run dev"
|
echo "Next: run npm run dev"
|
||||||
echo " (dev HTTPS and CoT TAK server TLS on port 8089 will use these certs)"
|
|
||||||
if [ "$IP" != "127.0.0.1" ] && [ "$IP" != "localhost" ]; then
|
if [ "$IP" != "127.0.0.1" ] && [ "$IP" != "localhost" ]; then
|
||||||
echo "On your phone: open https://${IP}:3000 (accept the security warning once)"
|
echo "On your phone: open https://${IP}:3000 (accept the security warning once)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { getAuthConfig } from '../../utils/authConfig.js'
|
import { getAuthConfig } from '../../utils/oidc.js'
|
||||||
|
|
||||||
export default defineEventHandler(() => getAuthConfig())
|
export default defineEventHandler(() => getAuthConfig())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { setCookie } from 'h3'
|
import { setCookie } from 'h3'
|
||||||
import { getDb } from '../../utils/db.js'
|
import { getDb } from '../../utils/db.js'
|
||||||
import { verifyPassword } from '../../utils/password.js'
|
import { verifyPassword } from '../../utils/password.js'
|
||||||
import { getSessionMaxAgeDays } from '../../utils/session.js'
|
import { getSessionMaxAgeDays } from '../../utils/constants.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
@@ -15,10 +15,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||||
throw createError({ statusCode: 401, message: 'Invalid credentials' })
|
throw createError({ statusCode: 401, message: 'Invalid credentials' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate all existing sessions for this user to prevent session fixation
|
// Invalidate all existing sessions for this user to prevent session fixation
|
||||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||||
|
|
||||||
const sessionDays = getSessionMaxAgeDays()
|
const sessionDays = getSessionMaxAgeDays()
|
||||||
const sid = crypto.randomUUID()
|
const sid = crypto.randomUUID()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAuthConfig } from '../../../utils/authConfig.js'
|
|
||||||
import {
|
import {
|
||||||
|
getAuthConfig,
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
getOidcRedirectUri,
|
getOidcRedirectUri,
|
||||||
createOidcParams,
|
createOidcParams,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
exchangeCode,
|
exchangeCode,
|
||||||
} from '../../../utils/oidc.js'
|
} from '../../../utils/oidc.js'
|
||||||
import { getDb } from '../../../utils/db.js'
|
import { getDb } from '../../../utils/db.js'
|
||||||
import { getSessionMaxAgeDays } from '../../../utils/session.js'
|
import { getSessionMaxAgeDays } from '../../../utils/constants.js'
|
||||||
|
|
||||||
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Invalidate all existing sessions for this user to prevent session fixation
|
// Invalidate all existing sessions for this user to prevent session fixation
|
||||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||||
|
|
||||||
const sessionDays = getSessionMaxAgeDays()
|
const sessionDays = getSessionMaxAgeDays()
|
||||||
const sid = crypto.randomUUID()
|
const sid = crypto.randomUUID()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
|
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization check: only session owner or admin/leader can consume
|
// Authorization check: only session owner or admin/leader can consume
|
||||||
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
||||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.producerId) {
|
if (!session.producerId) {
|
||||||
throw createError({ statusCode: 404, message: 'No producer available for this session' })
|
throw createError({ statusCode: 404, message: 'No producer available for this session' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { requireAuth } from '../../utils/authHelpers.js'
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = requireAuth(event)
|
const user = requireAuth(event)
|
||||||
if (!user.avatar_path) return { ok: true }
|
if (!user.avatar_path) return { ok: true }
|
||||||
|
|
||||||
// Validate avatar path to prevent path traversal attacks
|
// Validate avatar path to prevent path traversal attacks
|
||||||
const filename = user.avatar_path
|
const filename = user.avatar_path
|
||||||
if (!filename || !/^[a-f0-9-]+\.(jpg|jpeg|png)$/i.test(filename)) {
|
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join(getAvatarsDir(), filename)
|
const path = join(getAvatarsDir(), filename)
|
||||||
await unlink(path).catch(() => {})
|
await unlink(path).catch(() => {})
|
||||||
const { run } = await getDb()
|
const { run } = await getDb()
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = requireAuth(event)
|
const user = requireAuth(event)
|
||||||
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
|
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
|
||||||
|
|
||||||
// Validate avatar path to prevent path traversal attacks
|
// Validate avatar path to prevent path traversal attacks
|
||||||
const filename = user.avatar_path
|
const filename = user.avatar_path
|
||||||
if (!filename || !/^[a-f0-9-]+\.(jpg|jpeg|png)$/i.test(filename)) {
|
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join(getAvatarsDir(), filename)
|
const path = join(getAvatarsDir(), filename)
|
||||||
const ext = filename.split('.').pop()?.toLowerCase()
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
const mime = MIME[ext] ?? 'application/octet-stream'
|
const mime = MIME[ext] ?? 'application/octet-stream'
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
|
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
|
||||||
const mime = file.type ?? ''
|
const mime = file.type ?? ''
|
||||||
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
|
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
|
||||||
|
|
||||||
// Validate file content matches declared MIME type
|
// Validate file content matches declared MIME type
|
||||||
const actualMime = validateImageContent(file.data)
|
const actualMime = validateImageContent(file.data)
|
||||||
if (!actualMime || actualMime !== mime) {
|
if (!actualMime || actualMime !== mime) {
|
||||||
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
|
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
|
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
|
||||||
const filename = `${user.id}.${ext}`
|
const filename = `${user.id}.${ext}`
|
||||||
const dir = getAvatarsDir()
|
const dir = getAvatarsDir()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getDb } from '../utils/db.js'
|
import { getDb } from '../utils/db.js'
|
||||||
import { requireAuth } from '../utils/authHelpers.js'
|
import { requireAuth } from '../utils/authHelpers.js'
|
||||||
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
|
import { POI_ICON_TYPES } from '../utils/validation.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
requireAuth(event, { role: 'adminOrLeader' })
|
requireAuth(event, { role: 'adminOrLeader' })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getDb } from '../../utils/db.js'
|
import { getDb } from '../../utils/db.js'
|
||||||
import { requireAuth } from '../../utils/authHelpers.js'
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
|
import { POI_ICON_TYPES } from '../../utils/validation.js'
|
||||||
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getCookie } from 'h3'
|
import { getCookie } from 'h3'
|
||||||
import { getDb } from '../utils/db.js'
|
import { getDb } from '../utils/db.js'
|
||||||
import { skipAuth } from '../utils/authSkipPaths.js'
|
import { skipAuth } from '../utils/authHelpers.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
if (skipAuth(event.path)) return
|
if (skipAuth(event.path)) return
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default defineNitroPlugin((nitroApp) => {
|
|||||||
ws.send(JSON.stringify({ error: 'Session not found' }))
|
ws.send(JSON.stringify({ error: 'Session not found' }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only session owner or admin/leader can access the session
|
// Only session owner or admin/leader can access the session
|
||||||
if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
|
if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
|
||||||
ws.send(JSON.stringify({ error: 'Forbidden' }))
|
ws.send(JSON.stringify({ error: 'Forbidden' }))
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export function getAuthConfig() {
|
|
||||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
|
||||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
|
||||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
|
||||||
}
|
|
||||||
@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
|
|||||||
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth path utilities
|
||||||
|
export const SKIP_PATHS = Object.freeze([
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/api/auth/config',
|
||||||
|
'/api/auth/oidc/authorize',
|
||||||
|
'/api/auth/oidc/callback',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||||
|
'/api/cameras',
|
||||||
|
'/api/devices',
|
||||||
|
'/api/live',
|
||||||
|
'/api/me',
|
||||||
|
'/api/pois',
|
||||||
|
'/api/users',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function skipAuth(path) {
|
||||||
|
if (path.startsWith('/api/health') || path === '/health') return true
|
||||||
|
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
|
|
||||||
export const SKIP_PATHS = Object.freeze([
|
|
||||||
'/api/auth/login',
|
|
||||||
'/api/auth/logout',
|
|
||||||
'/api/auth/config',
|
|
||||||
'/api/auth/oidc/authorize',
|
|
||||||
'/api/auth/oidc/callback',
|
|
||||||
])
|
|
||||||
|
|
||||||
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
|
|
||||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
|
||||||
'/api/cameras',
|
|
||||||
'/api/devices',
|
|
||||||
'/api/live',
|
|
||||||
'/api/me',
|
|
||||||
'/api/pois',
|
|
||||||
'/api/users',
|
|
||||||
])
|
|
||||||
|
|
||||||
export function skipAuth(path) {
|
|
||||||
if (path.startsWith('/api/health') || path === '/health') return true
|
|
||||||
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
|
||||||
}
|
|
||||||
26
server/utils/bootstrap.js
vendored
26
server/utils/bootstrap.js
vendored
@@ -1,26 +0,0 @@
|
|||||||
import { randomBytes } from 'node:crypto'
|
|
||||||
import { hashPassword } from './password.js'
|
|
||||||
|
|
||||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
|
||||||
|
|
||||||
const generateRandomPassword = () =>
|
|
||||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
|
||||||
|
|
||||||
export async function bootstrapAdmin(run, get) {
|
|
||||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
|
||||||
if (row?.n !== 0) return
|
|
||||||
|
|
||||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
|
||||||
const password = process.env.BOOTSTRAP_PASSWORD
|
|
||||||
const identifier = (email && password) ? email : 'admin'
|
|
||||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
|
||||||
|
|
||||||
await run(
|
|
||||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,3 +21,10 @@ export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) |
|
|||||||
// Mediasoup
|
// Mediasoup
|
||||||
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
|
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
|
||||||
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
|
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
|
||||||
|
|
||||||
|
// Session
|
||||||
|
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||||
|
export function getSessionMaxAgeDays() {
|
||||||
|
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||||
|
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { XMLParser } from 'fast-xml-parser'
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
||||||
|
|
||||||
const TAK_MAGIC = 0xBF
|
// CoT protocol detection constants
|
||||||
|
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||||
|
export const COT_FIRST_BYTE_XML = 0x3C
|
||||||
|
|
||||||
|
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||||
|
export function isCotFirstByte(byte) {
|
||||||
|
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||||
|
}
|
||||||
|
|
||||||
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +38,7 @@ function readVarint(buf, offset) {
|
|||||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
|
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
|
||||||
*/
|
*/
|
||||||
export function parseTakStreamFrame(buf) {
|
export function parseTakStreamFrame(buf) {
|
||||||
if (!buf || buf.length < 2 || buf[0] !== TAK_MAGIC) return null
|
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
|
||||||
const { value: length, bytesRead } = readVarint(buf, 1)
|
const { value: length, bytesRead } = readVarint(buf, 1)
|
||||||
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
|
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
|
||||||
const bytesConsumed = 1 + bytesRead + length
|
const bytesConsumed = 1 + bytesRead + length
|
||||||
@@ -44,7 +52,7 @@ export function parseTakStreamFrame(buf) {
|
|||||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
|
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
|
||||||
*/
|
*/
|
||||||
export function parseTraditionalXmlFrame(buf) {
|
export function parseTraditionalXmlFrame(buf) {
|
||||||
if (!buf || buf.length < 8 || buf[0] !== 0x3C) return null
|
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
|
||||||
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
|
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
|
||||||
if (idx === -1) return null
|
if (idx === -1) return null
|
||||||
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
|
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* CoT stream first-byte detection: TAK Protocol (0xBF) or traditional XML (0x3C '<').
|
|
||||||
* Used by tests and any code that must distinguish CoT from other protocols.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
|
||||||
export const COT_FIRST_BYTE_XML = 0x3C
|
|
||||||
|
|
||||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
|
||||||
export function isCotFirstByte(byte) {
|
|
||||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@ import { join, dirname } from 'node:path'
|
|||||||
import { mkdirSync, existsSync } from 'node:fs'
|
import { mkdirSync, existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { bootstrapAdmin } from './bootstrap.js'
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { hashPassword } from './password.js'
|
||||||
import { registerCleanup } from './shutdown.js'
|
import { registerCleanup } from './shutdown.js'
|
||||||
|
|
||||||
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
|
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
|
||||||
@@ -152,7 +153,29 @@ const initDb = async (db, run, all, get) => {
|
|||||||
await run(SCHEMA.pois)
|
await run(SCHEMA.pois)
|
||||||
await run(SCHEMA.devices)
|
await run(SCHEMA.devices)
|
||||||
|
|
||||||
if (!testPath) await bootstrapAdmin(run, get)
|
if (!testPath) {
|
||||||
|
// Bootstrap admin user on first run
|
||||||
|
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||||
|
const generateRandomPassword = () =>
|
||||||
|
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||||
|
|
||||||
|
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||||
|
if (row?.n === 0) {
|
||||||
|
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||||
|
const password = process.env.BOOTSTRAP_PASSWORD
|
||||||
|
const identifier = (email && password) ? email : 'admin'
|
||||||
|
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||||
|
|
||||||
|
await run(
|
||||||
|
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom error classes and error handling utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base application error.
|
|
||||||
*/
|
|
||||||
export class AppError extends Error {
|
|
||||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
|
||||||
super(message)
|
|
||||||
this.name = this.constructor.name
|
|
||||||
this.statusCode = statusCode
|
|
||||||
this.code = code
|
|
||||||
Error.captureStackTrace(this, this.constructor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation error (400).
|
|
||||||
*/
|
|
||||||
export class ValidationError extends AppError {
|
|
||||||
constructor(message, details = null) {
|
|
||||||
super(message, 400, 'VALIDATION_ERROR')
|
|
||||||
this.details = details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Not found error (404).
|
|
||||||
*/
|
|
||||||
export class NotFoundError extends AppError {
|
|
||||||
constructor(resource = 'Resource') {
|
|
||||||
super(`${resource} not found`, 404, 'NOT_FOUND')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthorized error (401).
|
|
||||||
*/
|
|
||||||
export class UnauthorizedError extends AppError {
|
|
||||||
constructor(message = 'Unauthorized') {
|
|
||||||
super(message, 401, 'UNAUTHORIZED')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forbidden error (403).
|
|
||||||
*/
|
|
||||||
export class ForbiddenError extends AppError {
|
|
||||||
constructor(message = 'Forbidden') {
|
|
||||||
super(message, 403, 'FORBIDDEN')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conflict error (409).
|
|
||||||
*/
|
|
||||||
export class ConflictError extends AppError {
|
|
||||||
constructor(message = 'Conflict') {
|
|
||||||
super(message, 409, 'CONFLICT')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format error response for API.
|
|
||||||
* @param {Error} error - Error object
|
|
||||||
* @returns {object} Formatted error response
|
|
||||||
*/
|
|
||||||
export function formatErrorResponse(error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: error.code,
|
|
||||||
message: error.message,
|
|
||||||
...(error.details && { details: error.details }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: 'INTERNAL_ERROR',
|
|
||||||
message: error?.message || 'Internal server error',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
|
|||||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||||
const configCache = new Map()
|
const configCache = new Map()
|
||||||
|
|
||||||
|
// Auth configuration
|
||||||
|
export function getAuthConfig() {
|
||||||
|
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||||
|
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||||
|
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||||
|
}
|
||||||
|
|
||||||
function getRedirectUri() {
|
function getRedirectUri() {
|
||||||
const explicit
|
const explicit
|
||||||
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
|
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reusable query functions - eliminates SQL duplication across routes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const updateEntity = async (db, table, id, updates, getById) => {
|
|
||||||
if (Object.keys(updates).length === 0) return getById(db, id)
|
|
||||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
|
||||||
const { query, params } = buildUpdateQuery(table, null, updates)
|
|
||||||
if (!query) return getById(db, id)
|
|
||||||
await db.run(query, [...params, id])
|
|
||||||
return getById(db, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeviceById(db, id) {
|
|
||||||
const result = await db.get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
|
||||||
return result || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllDevices(db) {
|
|
||||||
return db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDevice(db, data) {
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[id, data.name, data.device_type, data.vendor, data.lat, data.lng, data.stream_url, data.source_type, data.config],
|
|
||||||
)
|
|
||||||
return getDeviceById(db, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateDevice(db, id, updates) {
|
|
||||||
return updateEntity(db, 'devices', id, updates, getDeviceById)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserById(db, id) {
|
|
||||||
const result = await db.get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
|
||||||
return result || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserByIdentifier(db, identifier) {
|
|
||||||
const result = await db.get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
|
|
||||||
return result || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createUser(db, data) {
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[id, data.identifier, data.password_hash, data.role, data.created_at, data.auth_provider || 'local', data.oidc_issuer || null, data.oidc_sub || null],
|
|
||||||
)
|
|
||||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUser(db, id, updates) {
|
|
||||||
if (Object.keys(updates).length === 0) return getUserById(db, id)
|
|
||||||
const { buildUpdateQuery } = await import('./queryBuilder.js')
|
|
||||||
const { query, params } = buildUpdateQuery('users', null, updates)
|
|
||||||
if (!query) return getUserById(db, id)
|
|
||||||
await db.run(query, [...params, id])
|
|
||||||
return db.get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPoiById(db, id) {
|
|
||||||
const result = await db.get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
|
||||||
return result || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllPois(db) {
|
|
||||||
return db.all('SELECT id, lat, lng, label, icon_type FROM pois ORDER BY id')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPoi(db, data) {
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO pois (id, lat, lng, label, icon_type) VALUES (?, ?, ?, ?, ?)',
|
|
||||||
[id, data.lat, data.lng, data.label || '', data.icon_type || 'pin'],
|
|
||||||
)
|
|
||||||
return getPoiById(db, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePoi(db, id, updates) {
|
|
||||||
return updateEntity(db, 'pois', id, updates, getPoiById)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionById(db, id) {
|
|
||||||
const result = await db.get('SELECT id, user_id, expires_at FROM sessions WHERE id = ?', [id])
|
|
||||||
return result || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDbSession(db, data) {
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
|
||||||
[data.id, data.user_id, data.created_at, data.expires_at],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSession(db, id) {
|
|
||||||
await db.run('DELETE FROM sessions WHERE id = ?', [id])
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Input sanitization utilities - pure functions for cleaning user input.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
|
||||||
|
|
||||||
const IDENTIFIER_REGEX = /^\w+$/
|
|
||||||
|
|
||||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
|
||||||
if (typeof str !== 'string') return ''
|
|
||||||
const trimmed = str.trim()
|
|
||||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeIdentifier(str) {
|
|
||||||
if (typeof str !== 'string') return ''
|
|
||||||
const trimmed = str.trim()
|
|
||||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
|
||||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
|
||||||
return sanitizeString(str, maxLength)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
|
||||||
|
|
||||||
export function getSessionMaxAgeDays() {
|
|
||||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
|
||||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Validation schemas - pure functions for consistent input validation.
|
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from './sanitize.js'
|
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||||
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
||||||
import { POI_ICON_TYPES } from './poiConstants.js'
|
|
||||||
|
// Constants
|
||||||
|
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||||
|
|
||||||
|
// Sanitization functions
|
||||||
|
const IDENTIFIER_REGEX = /^\w+$/
|
||||||
|
|
||||||
|
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||||
|
if (typeof str !== 'string') return ''
|
||||||
|
const trimmed = str.trim()
|
||||||
|
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeIdentifier(str) {
|
||||||
|
if (typeof str !== 'string') return ''
|
||||||
|
const trimmed = str.trim()
|
||||||
|
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||||
|
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||||
|
return sanitizeString(str, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
const ROLES = ['admin', 'leader', 'member']
|
const ROLES = ['admin', 'leader', 'member']
|
||||||
|
|
||||||
|
|||||||
54
test/helpers/env.js
Normal file
54
test/helpers/env.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Functional helpers for test environment management.
|
||||||
|
* Returns new objects instead of mutating process.env directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new env object with specified overrides
|
||||||
|
* @param {Record<string, string | undefined>} overrides - Env vars to set/override
|
||||||
|
* @returns {Record<string, string>} New env object
|
||||||
|
*/
|
||||||
|
export const withEnv = overrides => ({
|
||||||
|
...process.env,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(overrides).filter(([, v]) => v !== undefined),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new env object with specified vars removed
|
||||||
|
* @param {string[]} keys - Env var keys to remove
|
||||||
|
* @returns {Record<string, string>} New env object
|
||||||
|
*/
|
||||||
|
export const withoutEnv = (keys) => {
|
||||||
|
const result = { ...process.env }
|
||||||
|
for (const key of keys) {
|
||||||
|
delete result[key]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a function with a temporary env, restoring original after
|
||||||
|
* @param {Record<string, string | undefined>} env - Temporary env to use
|
||||||
|
* @param {() => any} fn - Function to execute
|
||||||
|
* @returns {any} Result of fn()
|
||||||
|
*/
|
||||||
|
export const withTemporaryEnv = (env, fn) => {
|
||||||
|
const original = { ...process.env }
|
||||||
|
try {
|
||||||
|
// Set defined values
|
||||||
|
Object.entries(env).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
process.env = original
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,84 +2,58 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||||
import CameraViewer from '../../app/components/CameraViewer.vue'
|
import CameraViewer from '../../app/components/CameraViewer.vue'
|
||||||
|
|
||||||
|
const createCamera = (overrides = {}) => ({
|
||||||
|
id: 't1',
|
||||||
|
name: 'Test Camera',
|
||||||
|
streamUrl: 'https://example.com/stream.mjpg',
|
||||||
|
sourceType: 'mjpeg',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
describe('CameraViewer (device stream)', () => {
|
describe('CameraViewer (device stream)', () => {
|
||||||
it('renders device name and close button', async () => {
|
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, {
|
const wrapper = await mountSuspended(CameraViewer, {
|
||||||
props: { camera },
|
props: { camera: createCamera({ name: 'Test Camera' }) },
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('Test Camera')
|
expect(wrapper.text()).toContain('Test Camera')
|
||||||
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not set img src for non-http streamUrl', async () => {
|
it.each([
|
||||||
const camera = {
|
['javascript:alert(1)', false],
|
||||||
id: 't2',
|
['https://example.com/cam.mjpg', true],
|
||||||
name: 'Bad',
|
])('handles streamUrl: %s -> img exists: %s', async (streamUrl, shouldExist) => {
|
||||||
streamUrl: 'javascript:alert(1)',
|
|
||||||
sourceType: 'mjpeg',
|
|
||||||
}
|
|
||||||
const wrapper = await mountSuspended(CameraViewer, {
|
const wrapper = await mountSuspended(CameraViewer, {
|
||||||
props: { camera },
|
props: { camera: createCamera({ streamUrl }) },
|
||||||
})
|
})
|
||||||
const img = wrapper.find('img')
|
const img = wrapper.find('img')
|
||||||
expect(img.exists()).toBe(false)
|
expect(img.exists()).toBe(shouldExist)
|
||||||
})
|
if (shouldExist) {
|
||||||
|
expect(img.attributes('src')).toBe(streamUrl)
|
||||||
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 () => {
|
it('emits close when close button clicked', async () => {
|
||||||
const camera = {
|
const wrapper = await mountSuspended(CameraViewer, {
|
||||||
id: 't5',
|
props: { camera: createCamera() },
|
||||||
name: 'Close me',
|
})
|
||||||
streamUrl: '',
|
|
||||||
sourceType: 'mjpeg',
|
|
||||||
}
|
|
||||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
|
||||||
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
|
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
|
||||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows stream unavailable when img errors', async () => {
|
it('shows stream unavailable when img errors', async () => {
|
||||||
const camera = {
|
const wrapper = await mountSuspended(CameraViewer, {
|
||||||
id: 't6',
|
props: { camera: createCamera({ streamUrl: 'https://example.com/bad.mjpg' }) },
|
||||||
name: 'Broken',
|
})
|
||||||
streamUrl: 'https://example.com/bad.mjpg',
|
await wrapper.find('img').trigger('error')
|
||||||
sourceType: 'mjpeg',
|
|
||||||
}
|
|
||||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
|
||||||
const img = wrapper.find('img')
|
|
||||||
await img.trigger('error')
|
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
expect(wrapper.text()).toContain('Stream unavailable')
|
expect(wrapper.text()).toContain('Stream unavailable')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders video element for hls sourceType', async () => {
|
it('renders video element for hls sourceType', async () => {
|
||||||
const camera = {
|
const wrapper = await mountSuspended(CameraViewer, {
|
||||||
id: 't7',
|
props: { camera: createCamera({ sourceType: 'hls', streamUrl: 'https://example.com/stream.m3u8' }) },
|
||||||
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)
|
expect(wrapper.find('video').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,58 +1,43 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||||
|
|
||||||
const withAuth = () => {
|
const mountDrawer = (props = {}) => {
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
return mountSuspended(NavDrawer, {
|
||||||
|
props: { modelValue: true, ...props },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('NavDrawer', () => {
|
describe('NavDrawer', () => {
|
||||||
it('renders navigation links with correct paths', async () => {
|
beforeEach(() => {
|
||||||
withAuth()
|
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||||
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 () => {
|
it('renders navigation links with correct paths', async () => {
|
||||||
withAuth()
|
await mountDrawer()
|
||||||
await mountSuspended(NavDrawer, {
|
const hrefs = [...document.body.querySelectorAll('aside nav a[href]')].map(a => a.getAttribute('href'))
|
||||||
props: { modelValue: true },
|
expect(hrefs).toEqual(expect.arrayContaining(['/', '/account', '/cameras', '/poi', '/members', '/settings']))
|
||||||
attachTo: document.body,
|
})
|
||||||
})
|
|
||||||
expect(document.body.textContent).toContain('Map')
|
it.each([
|
||||||
expect(document.body.textContent).toContain('Settings')
|
['Map'],
|
||||||
|
['Settings'],
|
||||||
|
])('renders %s label', async (label) => {
|
||||||
|
await mountDrawer()
|
||||||
|
expect(document.body.textContent).toContain(label)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits update:modelValue when close is triggered', async () => {
|
it('emits update:modelValue when close is triggered', async () => {
|
||||||
withAuth()
|
const wrapper = await mountDrawer()
|
||||||
const wrapper = await mountSuspended(NavDrawer, {
|
|
||||||
props: { modelValue: true },
|
|
||||||
attachTo: document.body,
|
|
||||||
})
|
|
||||||
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
|
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
|
||||||
await wrapper.vm.close()
|
await wrapper.vm.close()
|
||||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies active styling for current route', async () => {
|
it('applies active styling for current route', async () => {
|
||||||
withAuth()
|
await mountDrawer()
|
||||||
await mountSuspended(NavDrawer, {
|
|
||||||
props: { modelValue: true },
|
|
||||||
attachTo: document.body,
|
|
||||||
})
|
|
||||||
const mapLink = document.body.querySelector('aside nav a[href="/"]')
|
const mapLink = document.body.querySelector('aside nav a[href="/"]')
|
||||||
expect(mapLink).toBeTruthy()
|
expect(mapLink?.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
||||||
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
|||||||
import Index from '../../app/pages/index.vue'
|
import Index from '../../app/pages/index.vue'
|
||||||
import Login from '../../app/pages/login.vue'
|
import Login from '../../app/pages/login.vue'
|
||||||
|
|
||||||
|
const wait = (ms = 200) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const setupProtectedEndpoints = () => {
|
||||||
|
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||||
|
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
describe('auth middleware', () => {
|
describe('auth middleware', () => {
|
||||||
it('allows /login without redirect when unauthenticated', async () => {
|
it('allows /login without redirect when unauthenticated', async () => {
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||||
@@ -11,28 +18,25 @@ describe('auth middleware', () => {
|
|||||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
|
it.each([
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
[() => null, '/login', { redirect: '/' }],
|
||||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
[
|
||||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
() => {
|
||||||
|
throw createError({ statusCode: 401 })
|
||||||
|
},
|
||||||
|
'/login',
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
])('redirects to /login when unauthenticated: %s', async (meResponse, expectedPath, expectedQuery) => {
|
||||||
|
registerEndpoint('/api/me', meResponse, { method: 'GET' })
|
||||||
|
setupProtectedEndpoints()
|
||||||
await mountSuspended(Index)
|
await mountSuspended(Index)
|
||||||
await new Promise(r => setTimeout(r, 200))
|
await wait(meResponse.toString().includes('401') ? 250 : 200)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
expect(router.currentRoute.value.path).toBe('/login')
|
expect(router.currentRoute.value.path).toBe(expectedPath)
|
||||||
expect(router.currentRoute.value.query.redirect).toBe('/')
|
if (expectedQuery) {
|
||||||
})
|
expect(router.currentRoute.value.query).toMatchObject(expectedQuery)
|
||||||
|
}
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,46 +1,44 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import DefaultLayout from '../../app/layouts/default.vue'
|
import DefaultLayout from '../../app/layouts/default.vue'
|
||||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||||
|
|
||||||
const withAuth = () => {
|
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('default layout', () => {
|
describe('default layout', () => {
|
||||||
it('renders KestrelOS header', async () => {
|
beforeEach(() => {
|
||||||
withAuth()
|
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
|
||||||
expect(wrapper.text()).toContain('KestrelOS')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders drawer toggle with accessible label on mobile', async () => {
|
it.each([
|
||||||
withAuth()
|
['KestrelOS header', 'KestrelOS'],
|
||||||
|
['drawer toggle', 'button[aria-label="Toggle navigation"]'],
|
||||||
|
])('renders %s', async (description, selector) => {
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
|
if (selector.startsWith('button')) {
|
||||||
expect(toggle.exists()).toBe(true)
|
expect(wrapper.find(selector).exists()).toBe(true)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expect(wrapper.text()).toContain(selector)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders NavDrawer', async () => {
|
it('renders NavDrawer', async () => {
|
||||||
withAuth()
|
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders user menu and sign out navigates home', async () => {
|
it('renders user menu and sign out navigates home', async () => {
|
||||||
withAuth()
|
|
||||||
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait()
|
||||||
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
|
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
|
||||||
expect(menuTrigger.exists()).toBe(true)
|
|
||||||
await menuTrigger.trigger('click')
|
await menuTrigger.trigger('click')
|
||||||
await new Promise(r => setTimeout(r, 50))
|
await wait(50)
|
||||||
const signOut = wrapper.find('button[role="menuitem"]')
|
const signOut = wrapper.find('button[role="menuitem"]')
|
||||||
expect(signOut.exists()).toBe(true)
|
|
||||||
expect(signOut.text()).toContain('Sign out')
|
expect(signOut.text()).toContain('Sign out')
|
||||||
await signOut.trigger('click')
|
await signOut.trigger('click')
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
expect(router.currentRoute.value.path).toBe('/')
|
expect(router.currentRoute.value.path).toBe('/')
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import Index from '../../app/pages/index.vue'
|
import Index from '../../app/pages/index.vue'
|
||||||
|
|
||||||
|
const wait = (ms = 150) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
describe('index page', () => {
|
describe('index page', () => {
|
||||||
it('renders map and uses cameras', async () => {
|
it('renders map and uses cameras', async () => {
|
||||||
registerEndpoint('/api/cameras', () => ({
|
registerEndpoint('/api/cameras', () => ({
|
||||||
@@ -11,7 +13,7 @@ describe('index page', () => {
|
|||||||
registerEndpoint('/api/pois', () => [])
|
registerEndpoint('/api/pois', () => [])
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||||
const wrapper = await mountSuspended(Index)
|
const wrapper = await mountSuspended(Index)
|
||||||
await new Promise(r => setTimeout(r, 150))
|
await wait()
|
||||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
100
test/nuxt/logger.spec.js
Normal file
100
test/nuxt/logger.spec.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
|
import { readBody } from 'h3'
|
||||||
|
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
|
||||||
|
|
||||||
|
const wait = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
describe('app/utils/logger', () => {
|
||||||
|
const consoleMocks = {}
|
||||||
|
const originalConsole = {}
|
||||||
|
let serverCalls
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serverCalls = []
|
||||||
|
const calls = { log: [], error: [], warn: [], debug: [] }
|
||||||
|
|
||||||
|
Object.keys(calls).forEach((key) => {
|
||||||
|
originalConsole[key] = console[key]
|
||||||
|
consoleMocks[key] = vi.fn((...args) => calls[key].push(args))
|
||||||
|
console[key] = consoleMocks[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
registerEndpoint('/api/log', async (event) => {
|
||||||
|
const body = event.body || (await readBody(event).catch(() => ({})))
|
||||||
|
serverCalls.push(body)
|
||||||
|
return { ok: true }
|
||||||
|
}, { method: 'POST' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.keys(originalConsole).forEach((key) => {
|
||||||
|
console[key] = originalConsole[key]
|
||||||
|
})
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initLogger', () => {
|
||||||
|
it('sets sessionId and userId for server calls', async () => {
|
||||||
|
initLogger('session-123', 'user-456')
|
||||||
|
logError('Test message')
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(serverCalls[0]).toMatchObject({
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('log functions', () => {
|
||||||
|
it.each([
|
||||||
|
['logError', logError, 'error', 'error'],
|
||||||
|
['logWarn', logWarn, 'warn', 'warn'],
|
||||||
|
['logInfo', logInfo, 'info', 'log'],
|
||||||
|
['logDebug', logDebug, 'debug', 'log'],
|
||||||
|
])('%s logs to console and sends to server', async (name, logFn, level, consoleKey) => {
|
||||||
|
initLogger('session-123', 'user-456')
|
||||||
|
logFn('Test message', { key: 'value' })
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(consoleMocks[consoleKey]).toHaveBeenCalledWith(`[Test message]`, { key: 'value' })
|
||||||
|
expect(serverCalls[0]).toMatchObject({
|
||||||
|
level,
|
||||||
|
message: 'Test message',
|
||||||
|
data: { key: 'value' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles server fetch failure gracefully', async () => {
|
||||||
|
registerEndpoint('/api/log', () => {
|
||||||
|
throw new Error('Network error')
|
||||||
|
}, { method: 'POST' })
|
||||||
|
|
||||||
|
initLogger('session-123', 'user-456')
|
||||||
|
expect(() => logError('Test error')).not.toThrow()
|
||||||
|
await wait()
|
||||||
|
expect(consoleMocks.error).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendToServer', () => {
|
||||||
|
it('includes timestamp in server request', async () => {
|
||||||
|
initLogger('session-123', 'user-456')
|
||||||
|
logError('Test message')
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(serverCalls[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles null sessionId and userId', async () => {
|
||||||
|
initLogger(null, null)
|
||||||
|
logError('Test message')
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
const { sessionId, userId } = serverCalls[0]
|
||||||
|
expect(sessionId === null || sessionId === undefined).toBe(true)
|
||||||
|
expect(userId === null || userId === undefined).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,31 +2,34 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import Login from '../../app/pages/login.vue'
|
import Login from '../../app/pages/login.vue'
|
||||||
|
|
||||||
|
const wait = (ms = 50) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
describe('login page', () => {
|
describe('login page', () => {
|
||||||
it('renders sign in form (local auth always shown)', async () => {
|
it('renders sign in form (local auth always shown)', async () => {
|
||||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
|
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
|
||||||
const wrapper = await mountSuspended(Login)
|
const wrapper = await mountSuspended(Login)
|
||||||
await new Promise(r => setTimeout(r, 50))
|
await wait()
|
||||||
expect(wrapper.text()).toContain('Sign in')
|
expect(wrapper.text()).toContain('Sign in')
|
||||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows OIDC button when OIDC is enabled', async () => {
|
it.each([
|
||||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
|
[{ enabled: true, label: 'Sign in with Authentik' }, true, false],
|
||||||
|
[{ enabled: true, label: 'Sign in with OIDC' }, true, true],
|
||||||
|
])('shows OIDC when enabled: %j', async (oidcConfig, shouldShowButton, shouldShowPassword) => {
|
||||||
|
registerEndpoint('/api/auth/config', () => ({ oidc: oidcConfig }), { method: 'GET' })
|
||||||
await clearNuxtData('auth-config')
|
await clearNuxtData('auth-config')
|
||||||
const wrapper = await mountSuspended(Login)
|
const wrapper = await mountSuspended(Login)
|
||||||
await new Promise(r => setTimeout(r, 150))
|
await wait(150)
|
||||||
expect(wrapper.text()).toContain('Sign in with Authentik')
|
if (shouldShowButton) {
|
||||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||||
})
|
if (oidcConfig.label) {
|
||||||
|
expect(wrapper.text()).toContain(oidcConfig.label)
|
||||||
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')
|
if (shouldShowPassword) {
|
||||||
const wrapper = await mountSuspended(Login)
|
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,36 +2,41 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import Members from '../../app/pages/members.vue'
|
import Members from '../../app/pages/members.vue'
|
||||||
|
|
||||||
|
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const setupEndpoints = (userResponse) => {
|
||||||
|
registerEndpoint('/api/me', userResponse, { method: 'GET' })
|
||||||
|
registerEndpoint('/api/users', () => [])
|
||||||
|
}
|
||||||
|
|
||||||
describe('members page', () => {
|
describe('members page', () => {
|
||||||
it('renders Members heading', async () => {
|
it('renders Members heading', async () => {
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
setupEndpoints(() => null)
|
||||||
registerEndpoint('/api/users', () => [])
|
|
||||||
const wrapper = await mountSuspended(Members)
|
const wrapper = await mountSuspended(Members)
|
||||||
expect(wrapper.text()).toContain('Members')
|
expect(wrapper.text()).toContain('Members')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows sign in message when no user', async () => {
|
it('shows sign in message when no user', async () => {
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
setupEndpoints(() => null)
|
||||||
registerEndpoint('/api/users', () => [])
|
|
||||||
const wrapper = await mountSuspended(Members)
|
const wrapper = await mountSuspended(Members)
|
||||||
expect(wrapper.text()).toMatch(/Sign in to view members/)
|
expect(wrapper.text()).toMatch(/Sign in to view members/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows members list and Add user when user is admin', async () => {
|
it.each([
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'admin', role: 'admin', avatar_url: null }), { method: 'GET' })
|
[
|
||||||
registerEndpoint('/api/users', () => [])
|
{ id: '1', identifier: 'admin', role: 'admin', avatar_url: null },
|
||||||
|
['Add user', /Only admins can change roles/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: '2', identifier: 'leader', role: 'leader', avatar_url: null },
|
||||||
|
['Members', 'Identifier'],
|
||||||
|
],
|
||||||
|
])('shows content for %s role', async (user, expectedTexts) => {
|
||||||
|
setupEndpoints(() => user)
|
||||||
const wrapper = await mountSuspended(Members)
|
const wrapper = await mountSuspended(Members)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait(user.role === 'leader' ? 150 : 100)
|
||||||
expect(wrapper.text()).toContain('Add user')
|
expectedTexts.forEach((text) => {
|
||||||
expect(wrapper.text()).toMatch(/Only admins can change roles/)
|
expect(wrapper.text()).toMatch(text)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows members content when user has canEditPois (leader)', async () => {
|
|
||||||
registerEndpoint('/api/me', () => ({ id: '2', identifier: 'leader', role: 'leader', avatar_url: null }), { method: 'GET' })
|
|
||||||
registerEndpoint('/api/users', () => [])
|
|
||||||
const wrapper = await mountSuspended(Members)
|
|
||||||
await new Promise(r => setTimeout(r, 150))
|
|
||||||
expect(wrapper.text()).toContain('Members')
|
|
||||||
expect(wrapper.text()).toContain('Identifier')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import Poi from '../../app/pages/poi.vue'
|
import Poi from '../../app/pages/poi.vue'
|
||||||
|
|
||||||
describe('poi page', () => {
|
describe('poi page', () => {
|
||||||
it('renders POI placement heading', async () => {
|
beforeEach(() => {
|
||||||
registerEndpoint('/api/pois', () => [])
|
registerEndpoint('/api/pois', () => [])
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
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 () => {
|
it.each([
|
||||||
registerEndpoint('/api/pois', () => [])
|
['POI placement heading', 'POI placement'],
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
['view-only message', /View-only|Sign in as admin/],
|
||||||
|
])('renders %s', async (description, expected) => {
|
||||||
const wrapper = await mountSuspended(Poi)
|
const wrapper = await mountSuspended(Poi)
|
||||||
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
|
expect(wrapper.text()).toMatch(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,44 +2,45 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
import Index from '../../app/pages/index.vue'
|
import Index from '../../app/pages/index.vue'
|
||||||
|
|
||||||
|
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const setupEndpoints = (camerasResponse) => {
|
||||||
|
registerEndpoint('/api/cameras', camerasResponse)
|
||||||
|
registerEndpoint('/api/pois', () => [])
|
||||||
|
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
describe('useCameras', () => {
|
describe('useCameras', () => {
|
||||||
it('page uses cameras from API', async () => {
|
it('page uses cameras from API', async () => {
|
||||||
registerEndpoint('/api/cameras', () => ({
|
setupEndpoints(() => ({
|
||||||
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
|
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
|
||||||
liveSessions: [],
|
liveSessions: [],
|
||||||
cotEntities: [],
|
cotEntities: [],
|
||||||
}))
|
}))
|
||||||
registerEndpoint('/api/pois', () => [])
|
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
|
||||||
const wrapper = await mountSuspended(Index)
|
const wrapper = await mountSuspended(Index)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait()
|
||||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes cotEntities from API', async () => {
|
it('exposes cotEntities from API', async () => {
|
||||||
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
|
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
|
||||||
registerEndpoint('/api/cameras', () => ({
|
setupEndpoints(() => ({
|
||||||
devices: [],
|
devices: [],
|
||||||
liveSessions: [],
|
liveSessions: [],
|
||||||
cotEntities,
|
cotEntities,
|
||||||
}))
|
}))
|
||||||
registerEndpoint('/api/pois', () => [])
|
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
|
||||||
const wrapper = await mountSuspended(Index)
|
const wrapper = await mountSuspended(Index)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait()
|
||||||
const map = wrapper.findComponent({ name: 'KestrelMap' })
|
const map = wrapper.findComponent({ name: 'KestrelMap' })
|
||||||
expect(map.exists()).toBe(true)
|
|
||||||
expect(map.props('cotEntities')).toEqual(cotEntities)
|
expect(map.props('cotEntities')).toEqual(cotEntities)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles API error and falls back to empty devices and liveSessions', async () => {
|
it('handles API error and falls back to empty devices and liveSessions', async () => {
|
||||||
registerEndpoint('/api/cameras', () => {
|
setupEndpoints(() => {
|
||||||
throw new Error('network')
|
throw new Error('network')
|
||||||
})
|
})
|
||||||
registerEndpoint('/api/pois', () => [])
|
|
||||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
|
||||||
const wrapper = await mountSuspended(Index)
|
const wrapper = await mountSuspended(Index)
|
||||||
await new Promise(r => setTimeout(r, 150))
|
await wait(150)
|
||||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,55 +3,56 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
|||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
|
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
|
||||||
|
|
||||||
|
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const createTestComponent = (setupFn) => {
|
||||||
|
return defineComponent({
|
||||||
|
setup: setupFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupEndpoints = (liveResponse) => {
|
||||||
|
registerEndpoint('/api/live', liveResponse)
|
||||||
|
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
describe('useLiveSessions', () => {
|
describe('useLiveSessions', () => {
|
||||||
it('fetches sessions from API and returns sessions ref', async () => {
|
it('fetches sessions from API and returns sessions ref', async () => {
|
||||||
registerEndpoint('/api/live', () => [
|
setupEndpoints(() => [{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 }])
|
||||||
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
|
const TestComponent = createTestComponent(() => {
|
||||||
])
|
const { sessions } = useLiveSessions()
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||||
const TestComponent = defineComponent({
|
|
||||||
setup() {
|
|
||||||
const { sessions } = useLiveSessions()
|
|
||||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const wrapper = await mountSuspended(TestComponent)
|
const wrapper = await mountSuspended(TestComponent)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await wait()
|
||||||
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
|
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns empty array when fetch fails', async () => {
|
it('returns empty array when fetch fails', async () => {
|
||||||
registerEndpoint('/api/live', () => {
|
setupEndpoints(() => {
|
||||||
throw new Error('fetch failed')
|
throw new Error('fetch failed')
|
||||||
})
|
})
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
const TestComponent = createTestComponent(() => {
|
||||||
const TestComponent = defineComponent({
|
const { sessions } = useLiveSessions()
|
||||||
setup() {
|
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||||
const { sessions } = useLiveSessions()
|
|
||||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const wrapper = await mountSuspended(TestComponent)
|
const wrapper = await mountSuspended(TestComponent)
|
||||||
await new Promise(r => setTimeout(r, 150))
|
await wait(150)
|
||||||
const el = wrapper.find('[data-sessions]')
|
const sessions = JSON.parse(wrapper.find('[data-sessions]').attributes('data-sessions'))
|
||||||
expect(el.exists()).toBe(true)
|
expect(sessions).toEqual([])
|
||||||
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('startPolling and stopPolling manage interval', async () => {
|
it('startPolling and stopPolling manage interval', async () => {
|
||||||
registerEndpoint('/api/live', () => [])
|
setupEndpoints(() => [])
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
const TestComponent = createTestComponent(() => {
|
||||||
const TestComponent = defineComponent({
|
const { startPolling, stopPolling } = useLiveSessions()
|
||||||
setup() {
|
return () => h('div', {
|
||||||
const { startPolling, stopPolling } = useLiveSessions()
|
onClick: () => {
|
||||||
return () => h('div', {
|
startPolling()
|
||||||
onClick: () => {
|
startPolling()
|
||||||
startPolling()
|
stopPolling()
|
||||||
startPolling()
|
},
|
||||||
stopPolling()
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const wrapper = await mountSuspended(TestComponent)
|
const wrapper = await mountSuspended(TestComponent)
|
||||||
await wrapper.trigger('click')
|
await wrapper.trigger('click')
|
||||||
|
|||||||
@@ -1,43 +1,51 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { getAuthConfig } from '../../server/utils/authConfig.js'
|
import { getAuthConfig } from '../../server/utils/oidc.js'
|
||||||
|
import { withTemporaryEnv } from '../helpers/env.js'
|
||||||
|
|
||||||
describe('authConfig', () => {
|
describe('authConfig', () => {
|
||||||
const origEnv = { ...process.env }
|
it('returns oidc disabled when OIDC env vars are unset', () => {
|
||||||
|
withTemporaryEnv(
|
||||||
afterEach(() => {
|
{ OIDC_ISSUER: undefined, OIDC_CLIENT_ID: undefined, OIDC_CLIENT_SECRET: undefined },
|
||||||
process.env = { ...origEnv }
|
() => {
|
||||||
|
expect(getAuthConfig()).toEqual({ oidc: { enabled: false, label: '' } })
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns oidc disabled when OIDC env vars are unset', () => {
|
it.each([
|
||||||
delete process.env.OIDC_ISSUER
|
[{ OIDC_ISSUER: 'https://auth.example.com' }, false],
|
||||||
delete process.env.OIDC_CLIENT_ID
|
[{ OIDC_CLIENT_ID: 'client' }, false],
|
||||||
delete process.env.OIDC_CLIENT_SECRET
|
[{ OIDC_ISSUER: 'https://auth.example.com', OIDC_CLIENT_ID: 'client' }, false],
|
||||||
expect(getAuthConfig()).toEqual({
|
])('returns oidc disabled when only some vars are set: %j', (env, expected) => {
|
||||||
oidc: { enabled: false, label: '' },
|
withTemporaryEnv({ ...env, OIDC_CLIENT_SECRET: undefined }, () => {
|
||||||
|
expect(getAuthConfig().oidc.enabled).toBe(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns oidc disabled when only some OIDC vars are set', () => {
|
it('returns oidc enabled with default label when all vars are set', () => {
|
||||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
withTemporaryEnv(
|
||||||
process.env.OIDC_CLIENT_ID = 'client'
|
{
|
||||||
delete process.env.OIDC_CLIENT_SECRET
|
OIDC_ISSUER: 'https://auth.example.com',
|
||||||
expect(getAuthConfig().oidc.enabled).toBe(false)
|
OIDC_CLIENT_ID: 'client',
|
||||||
})
|
OIDC_CLIENT_SECRET: 'secret',
|
||||||
|
},
|
||||||
it('returns oidc enabled and default label when all OIDC vars are set', () => {
|
() => {
|
||||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
expect(getAuthConfig()).toEqual({ oidc: { enabled: true, label: 'Sign in with OIDC' } })
|
||||||
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', () => {
|
it('uses OIDC_LABEL when set', () => {
|
||||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
withTemporaryEnv(
|
||||||
process.env.OIDC_CLIENT_ID = 'client'
|
{
|
||||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
OIDC_ISSUER: 'https://auth.example.com',
|
||||||
process.env.OIDC_LABEL = 'Sign in with Authentik'
|
OIDC_CLIENT_ID: 'client',
|
||||||
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
|
OIDC_CLIENT_SECRET: 'secret',
|
||||||
|
OIDC_LABEL: 'Sign in with Authentik',
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { requireAuth } from '../../server/utils/authHelpers.js'
|
import { requireAuth } from '../../server/utils/authHelpers.js'
|
||||||
|
|
||||||
function mockEvent(user = null) {
|
const mockEvent = (user = null) => ({ context: { user } })
|
||||||
return { context: { user } }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('authHelpers', () => {
|
describe('authHelpers', () => {
|
||||||
it('requireAuth throws 401 when no user', () => {
|
it('requireAuth throws 401 when no user', () => {
|
||||||
@@ -19,43 +17,29 @@ describe('authHelpers', () => {
|
|||||||
|
|
||||||
it('requireAuth returns user when set', () => {
|
it('requireAuth returns user when set', () => {
|
||||||
const user = { id: '1', identifier: 'a@b.com', role: 'member' }
|
const user = { id: '1', identifier: 'a@b.com', role: 'member' }
|
||||||
|
expect(requireAuth(mockEvent(user))).toEqual(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['member', 'adminOrLeader', 403],
|
||||||
|
['admin', 'adminOrLeader', null],
|
||||||
|
['leader', 'adminOrLeader', null],
|
||||||
|
['leader', 'admin', 403],
|
||||||
|
['admin', 'admin', null],
|
||||||
|
])('requireAuth with %s role and %s requirement', (userRole, requirement, expectedStatus) => {
|
||||||
|
const user = { id: '1', identifier: 'a', role: userRole }
|
||||||
const event = mockEvent(user)
|
const event = mockEvent(user)
|
||||||
expect(requireAuth(event)).toEqual(user)
|
if (expectedStatus === null) {
|
||||||
})
|
expect(requireAuth(event, { role: requirement })).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) {
|
else {
|
||||||
expect(e.statusCode).toBe(403)
|
expect(() => requireAuth(event, { role: requirement })).toThrow()
|
||||||
|
try {
|
||||||
|
requireAuth(event, { role: requirement })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
expect(e.statusCode).toBe(expectedStatus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Ensures no API route that requires auth (requireAuth with optional role)
|
* 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
|
* 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.
|
* PROTECTED_PATH_PREFIXES in server/utils/authHelpers.js so these tests fail if it gets skipped.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authSkipPaths.js'
|
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authHelpers.js'
|
||||||
|
|
||||||
describe('authSkipPaths', () => {
|
describe('authSkipPaths', () => {
|
||||||
it('does not skip any protected path (auth required for these)', () => {
|
it('does not skip any protected path', () => {
|
||||||
for (const path of PROTECTED_PATH_PREFIXES) {
|
const protectedPaths = [
|
||||||
|
...PROTECTED_PATH_PREFIXES,
|
||||||
|
'/api/cameras',
|
||||||
|
'/api/devices',
|
||||||
|
'/api/devices/any-id',
|
||||||
|
'/api/me',
|
||||||
|
'/api/pois',
|
||||||
|
'/api/pois/any-id',
|
||||||
|
'/api/users',
|
||||||
|
'/api/users/any-id',
|
||||||
|
]
|
||||||
|
protectedPaths.forEach((path) => {
|
||||||
expect(skipAuth(path)).toBe(false)
|
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', () => {
|
it.each([
|
||||||
expect(skipAuth('/api/auth/login')).toBe(true)
|
'/api/auth/login',
|
||||||
expect(skipAuth('/api/auth/logout')).toBe(true)
|
'/api/auth/logout',
|
||||||
expect(skipAuth('/api/auth/config')).toBe(true)
|
'/api/auth/config',
|
||||||
expect(skipAuth('/api/auth/oidc/authorize')).toBe(true)
|
'/api/auth/oidc/authorize',
|
||||||
expect(skipAuth('/api/auth/oidc/callback')).toBe(true)
|
'/api/auth/oidc/callback',
|
||||||
expect(skipAuth('/api/health')).toBe(true)
|
'/api/health',
|
||||||
expect(skipAuth('/api/health/ready')).toBe(true)
|
'/api/health/ready',
|
||||||
expect(skipAuth('/health')).toBe(true)
|
'/health',
|
||||||
|
])('skips public path: %s', (path) => {
|
||||||
|
expect(skipAuth(path)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps SKIP_PATHS and PROTECTED_PATH_PREFIXES disjoint', () => {
|
it('keeps SKIP_PATHS and PROTECTED_PATH_PREFIXES disjoint', () => {
|
||||||
|
|||||||
51
test/unit/bootstrap.spec.js
vendored
51
test/unit/bootstrap.spec.js
vendored
@@ -1,51 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
||||||
import { bootstrapAdmin } from '../../server/utils/bootstrap.js'
|
|
||||||
|
|
||||||
describe('bootstrapAdmin', () => {
|
|
||||||
let run
|
|
||||||
let get
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
run = vi.fn().mockResolvedValue(undefined)
|
|
||||||
get = vi.fn()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
delete process.env.BOOTSTRAP_EMAIL
|
|
||||||
delete process.env.BOOTSTRAP_PASSWORD
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns without inserting when users exist', async () => {
|
|
||||||
get.mockResolvedValue({ n: 1 })
|
|
||||||
await bootstrapAdmin(run, get)
|
|
||||||
expect(get).toHaveBeenCalledWith('SELECT COUNT(*) as n FROM users')
|
|
||||||
expect(run).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts default admin when no users and no env', async () => {
|
|
||||||
get.mockResolvedValue({ n: 0 })
|
|
||||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
||||||
await bootstrapAdmin(run, get)
|
|
||||||
expect(run).toHaveBeenCalledTimes(1)
|
|
||||||
const args = run.mock.calls[0][1]
|
|
||||||
expect(args[1]).toBe('admin') // identifier
|
|
||||||
expect(args[3]).toBe('admin') // role
|
|
||||||
expect(logSpy).toHaveBeenCalled()
|
|
||||||
logSpy.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts admin with BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD when set', async () => {
|
|
||||||
get.mockResolvedValue({ n: 0 })
|
|
||||||
process.env.BOOTSTRAP_EMAIL = ' admin@example.com '
|
|
||||||
process.env.BOOTSTRAP_PASSWORD = 'secret123'
|
|
||||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
||||||
await bootstrapAdmin(run, get)
|
|
||||||
expect(run).toHaveBeenCalledTimes(1)
|
|
||||||
const args = run.mock.calls[0][1]
|
|
||||||
expect(args[1]).toBe('admin@example.com') // identifier
|
|
||||||
expect(args[3]).toBe('admin') // role
|
|
||||||
expect(logSpy).not.toHaveBeenCalled()
|
|
||||||
logSpy.mockRestore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
COT_AUTH_TIMEOUT_MS,
|
COT_AUTH_TIMEOUT_MS,
|
||||||
LIVE_SESSION_TTL_MS,
|
LIVE_SESSION_TTL_MS,
|
||||||
@@ -15,16 +15,6 @@ import {
|
|||||||
} from '../../server/utils/constants.js'
|
} from '../../server/utils/constants.js'
|
||||||
|
|
||||||
describe('constants', () => {
|
describe('constants', () => {
|
||||||
const originalEnv = process.env
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv }
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses default values when env vars not set', () => {
|
it('uses default values when env vars not set', () => {
|
||||||
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
|
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
|
||||||
expect(LIVE_SESSION_TTL_MS).toBe(60000)
|
expect(LIVE_SESSION_TTL_MS).toBe(60000)
|
||||||
@@ -40,34 +30,11 @@ describe('constants', () => {
|
|||||||
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
|
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses env var values when set', () => {
|
|
||||||
process.env.COT_AUTH_TIMEOUT_MS = '20000'
|
|
||||||
process.env.LIVE_SESSION_TTL_MS = '120000'
|
|
||||||
process.env.COT_PORT = '9090'
|
|
||||||
process.env.MAX_STRING_LENGTH = '2000'
|
|
||||||
|
|
||||||
// Re-import to get new values
|
|
||||||
const {
|
|
||||||
COT_AUTH_TIMEOUT_MS: timeout,
|
|
||||||
LIVE_SESSION_TTL_MS: ttl,
|
|
||||||
COT_PORT: port,
|
|
||||||
MAX_STRING_LENGTH: maxLen,
|
|
||||||
} = require('../../server/utils/constants.js')
|
|
||||||
|
|
||||||
// Note: In actual usage, constants are evaluated at module load time
|
|
||||||
// This test verifies the pattern works
|
|
||||||
expect(typeof timeout).toBe('number')
|
|
||||||
expect(typeof ttl).toBe('number')
|
|
||||||
expect(typeof port).toBe('number')
|
|
||||||
expect(typeof maxLen).toBe('number')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles invalid env var values gracefully', () => {
|
it('handles invalid env var values gracefully', () => {
|
||||||
// Constants are evaluated at module load time, so env vars set in tests won't affect them
|
// Constants are evaluated at module load time, so env vars set in tests won't affect them
|
||||||
// This test verifies the pattern: Number(process.env.VAR) || default
|
// This test verifies the pattern: Number(process.env.VAR) || default
|
||||||
const invalidValue = Number('invalid')
|
const invalidValue = Number('invalid')
|
||||||
expect(Number.isNaN(invalidValue)).toBe(true)
|
expect(Number.isNaN(invalidValue)).toBe(true)
|
||||||
const fallback = invalidValue || 15000
|
expect(invalidValue || 15000).toBe(15000)
|
||||||
expect(fallback).toBe(15000)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { isCotFirstByte, COT_FIRST_BYTE_TAK, COT_FIRST_BYTE_XML } from '../../server/utils/cotRouter.js'
|
import { isCotFirstByte, COT_FIRST_BYTE_TAK, COT_FIRST_BYTE_XML } from '../../server/utils/cotParser.js'
|
||||||
|
|
||||||
describe('cotRouter', () => {
|
describe('cotRouter', () => {
|
||||||
describe('isCotFirstByte', () => {
|
describe('isCotFirstByte', () => {
|
||||||
it('returns true for TAK Protocol (0xBF)', () => {
|
it.each([
|
||||||
expect(isCotFirstByte(0xBF)).toBe(true)
|
[0xBF, true],
|
||||||
expect(isCotFirstByte(COT_FIRST_BYTE_TAK)).toBe(true)
|
[COT_FIRST_BYTE_TAK, true],
|
||||||
|
[0x3C, true],
|
||||||
|
[COT_FIRST_BYTE_XML, true],
|
||||||
|
])('returns true for valid COT bytes: 0x%02X', (byte, expected) => {
|
||||||
|
expect(isCotFirstByte(byte)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns true for traditional XML (<)', () => {
|
it.each([
|
||||||
expect(isCotFirstByte(0x3C)).toBe(true)
|
[0x47, false], // 'G' GET
|
||||||
expect(isCotFirstByte(COT_FIRST_BYTE_XML)).toBe(true)
|
[0x50, false], // 'P' POST
|
||||||
})
|
[0x48, false], // 'H' HEAD
|
||||||
|
[0x00, false],
|
||||||
it('returns false for HTTP-like first bytes', () => {
|
[0x16, false], // TLS client hello
|
||||||
expect(isCotFirstByte(0x47)).toBe(false) // 'G' GET
|
])('returns false for non-COT bytes: 0x%02X', (byte, expected) => {
|
||||||
expect(isCotFirstByte(0x50)).toBe(false) // 'P' POST
|
expect(isCotFirstByte(byte)).toBe(expected)
|
||||||
expect(isCotFirstByte(0x48)).toBe(false) // 'H' HEAD
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for other bytes', () => {
|
|
||||||
expect(isCotFirstByte(0x00)).toBe(false)
|
|
||||||
expect(isCotFirstByte(0x16)).toBe(false) // TLS client hello
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
136
test/unit/cotSsl.spec.js
Normal file
136
test/unit/cotSsl.spec.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { existsSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import {
|
||||||
|
TRUSTSTORE_PASSWORD,
|
||||||
|
DEFAULT_COT_PORT,
|
||||||
|
getCotPort,
|
||||||
|
COT_TLS_REQUIRED_MESSAGE,
|
||||||
|
getCotSslPaths,
|
||||||
|
buildP12FromCertPath,
|
||||||
|
} from '../../server/utils/cotSsl.js'
|
||||||
|
import { withTemporaryEnv } from '../helpers/env.js'
|
||||||
|
|
||||||
|
describe('cotSsl', () => {
|
||||||
|
let testCertDir
|
||||||
|
let testCertPath
|
||||||
|
let testKeyPath
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testCertDir = join(tmpdir(), `kestrelos-test-${Date.now()}`)
|
||||||
|
mkdirSync(testCertDir, { recursive: true })
|
||||||
|
testCertPath = join(testCertDir, 'cert.pem')
|
||||||
|
testKeyPath = join(testCertDir, 'key.pem')
|
||||||
|
writeFileSync(testCertPath, '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n')
|
||||||
|
writeFileSync(testKeyPath, '-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
if (existsSync(testCertPath)) unlinkSync(testCertPath)
|
||||||
|
if (existsSync(testKeyPath)) unlinkSync(testKeyPath)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it.each([
|
||||||
|
['TRUSTSTORE_PASSWORD', TRUSTSTORE_PASSWORD, 'kestrelos'],
|
||||||
|
['DEFAULT_COT_PORT', DEFAULT_COT_PORT, 8089],
|
||||||
|
])('exports %s', (name, value, expected) => {
|
||||||
|
expect(value).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports COT_TLS_REQUIRED_MESSAGE', () => {
|
||||||
|
expect(COT_TLS_REQUIRED_MESSAGE).toContain('SSL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCotPort', () => {
|
||||||
|
it.each([
|
||||||
|
[{ COT_PORT: undefined }, DEFAULT_COT_PORT],
|
||||||
|
[{ COT_PORT: '9999' }, 9999],
|
||||||
|
[{ COT_PORT: '8080' }, 8080],
|
||||||
|
])('returns correct port for env: %j', (env, expected) => {
|
||||||
|
withTemporaryEnv(env, () => {
|
||||||
|
expect(getCotPort()).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCotSslPaths', () => {
|
||||||
|
it('returns paths from env vars when available, otherwise checks default locations', () => {
|
||||||
|
withTemporaryEnv({ COT_SSL_CERT: undefined, COT_SSL_KEY: undefined }, () => {
|
||||||
|
const result = getCotSslPaths()
|
||||||
|
if (result !== null) {
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
certPath: expect.any(String),
|
||||||
|
keyPath: expect.any(String),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expect(result).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns paths from COT_SSL_CERT and COT_SSL_KEY env vars', () => {
|
||||||
|
withTemporaryEnv({ COT_SSL_CERT: testCertPath, COT_SSL_KEY: testKeyPath }, () => {
|
||||||
|
expect(getCotSslPaths()).toEqual({ certPath: testCertPath, keyPath: testKeyPath })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns paths from config parameter when env vars not set', () => {
|
||||||
|
withTemporaryEnv({ COT_SSL_CERT: undefined, COT_SSL_KEY: undefined }, () => {
|
||||||
|
const config = { cotSslCert: testCertPath, cotSslKey: testKeyPath }
|
||||||
|
expect(getCotSslPaths(config)).toEqual({ certPath: testCertPath, keyPath: testKeyPath })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers env vars over config parameter', () => {
|
||||||
|
withTemporaryEnv({ COT_SSL_CERT: testCertPath, COT_SSL_KEY: testKeyPath }, () => {
|
||||||
|
const config = { cotSslCert: '/other/cert.pem', cotSslKey: '/other/key.pem' }
|
||||||
|
expect(getCotSslPaths(config)).toEqual({ certPath: testCertPath, keyPath: testKeyPath })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns paths from config even if files do not exist', () => {
|
||||||
|
withTemporaryEnv({ COT_SSL_CERT: undefined, COT_SSL_KEY: undefined }, () => {
|
||||||
|
const result = getCotSslPaths({ cotSslCert: '/nonexistent/cert.pem', cotSslKey: '/nonexistent/key.pem' })
|
||||||
|
expect(result).toEqual({ certPath: '/nonexistent/cert.pem', keyPath: '/nonexistent/key.pem' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildP12FromCertPath', () => {
|
||||||
|
it('throws error when cert file does not exist', () => {
|
||||||
|
expect(() => {
|
||||||
|
buildP12FromCertPath('/nonexistent/cert.pem', 'password')
|
||||||
|
}).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when openssl command fails', () => {
|
||||||
|
const invalidCertPath = join(testCertDir, 'invalid.pem')
|
||||||
|
writeFileSync(invalidCertPath, 'invalid cert content')
|
||||||
|
expect(() => {
|
||||||
|
buildP12FromCertPath(invalidCertPath, 'password')
|
||||||
|
}).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up temp file on error', () => {
|
||||||
|
const invalidCertPath = join(testCertDir, 'invalid.pem')
|
||||||
|
writeFileSync(invalidCertPath, 'invalid cert content')
|
||||||
|
try {
|
||||||
|
buildP12FromCertPath(invalidCertPath, 'password')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
// Function should clean up on error - test passes if no exception during cleanup
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
AppError,
|
|
||||||
ValidationError,
|
|
||||||
NotFoundError,
|
|
||||||
UnauthorizedError,
|
|
||||||
ForbiddenError,
|
|
||||||
ConflictError,
|
|
||||||
formatErrorResponse,
|
|
||||||
} from '../../server/utils/errors.js'
|
|
||||||
|
|
||||||
describe('errors', () => {
|
|
||||||
describe('AppError', () => {
|
|
||||||
it('creates error with default status code', () => {
|
|
||||||
const error = new AppError('Test error')
|
|
||||||
expect(error.message).toBe('Test error')
|
|
||||||
expect(error.statusCode).toBe(500)
|
|
||||||
expect(error.code).toBe('INTERNAL_ERROR')
|
|
||||||
expect(error).toBeInstanceOf(Error)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates error with custom status code and code', () => {
|
|
||||||
const error = new AppError('Custom error', 400, 'CUSTOM_CODE')
|
|
||||||
expect(error.statusCode).toBe(400)
|
|
||||||
expect(error.code).toBe('CUSTOM_CODE')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ValidationError', () => {
|
|
||||||
it('creates validation error with 400 status', () => {
|
|
||||||
const error = new ValidationError('Invalid input')
|
|
||||||
expect(error.statusCode).toBe(400)
|
|
||||||
expect(error.code).toBe('VALIDATION_ERROR')
|
|
||||||
expect(error.details).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes details when provided', () => {
|
|
||||||
const details = { field: 'email', reason: 'invalid format' }
|
|
||||||
const error = new ValidationError('Invalid input', details)
|
|
||||||
expect(error.details).toEqual(details)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('NotFoundError', () => {
|
|
||||||
it('creates not found error with default message', () => {
|
|
||||||
const error = new NotFoundError()
|
|
||||||
expect(error.statusCode).toBe(404)
|
|
||||||
expect(error.code).toBe('NOT_FOUND')
|
|
||||||
expect(error.message).toBe('Resource not found')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates not found error with custom resource', () => {
|
|
||||||
const error = new NotFoundError('User')
|
|
||||||
expect(error.message).toBe('User not found')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('UnauthorizedError', () => {
|
|
||||||
it('creates unauthorized error', () => {
|
|
||||||
const error = new UnauthorizedError()
|
|
||||||
expect(error.statusCode).toBe(401)
|
|
||||||
expect(error.code).toBe('UNAUTHORIZED')
|
|
||||||
expect(error.message).toBe('Unauthorized')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates unauthorized error with custom message', () => {
|
|
||||||
const error = new UnauthorizedError('Invalid credentials')
|
|
||||||
expect(error.message).toBe('Invalid credentials')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ForbiddenError', () => {
|
|
||||||
it('creates forbidden error', () => {
|
|
||||||
const error = new ForbiddenError()
|
|
||||||
expect(error.statusCode).toBe(403)
|
|
||||||
expect(error.code).toBe('FORBIDDEN')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ConflictError', () => {
|
|
||||||
it('creates conflict error', () => {
|
|
||||||
const error = new ConflictError()
|
|
||||||
expect(error.statusCode).toBe(409)
|
|
||||||
expect(error.code).toBe('CONFLICT')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('formatErrorResponse', () => {
|
|
||||||
it('formats AppError correctly', () => {
|
|
||||||
const error = new ValidationError('Invalid input', { field: 'email' })
|
|
||||||
const response = formatErrorResponse(error)
|
|
||||||
expect(response).toEqual({
|
|
||||||
error: {
|
|
||||||
code: 'VALIDATION_ERROR',
|
|
||||||
message: 'Invalid input',
|
|
||||||
details: { field: 'email' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('formats generic Error correctly', () => {
|
|
||||||
const error = new Error('Generic error')
|
|
||||||
const response = formatErrorResponse(error)
|
|
||||||
expect(response).toEqual({
|
|
||||||
error: {
|
|
||||||
code: 'INTERNAL_ERROR',
|
|
||||||
message: 'Generic error',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles error without message', () => {
|
|
||||||
const error = {}
|
|
||||||
const response = formatErrorResponse(error)
|
|
||||||
expect(response.error.message).toBe('Internal server error')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
constantTimeCompare,
|
constantTimeCompare,
|
||||||
validateRedirectPath,
|
validateRedirectPath,
|
||||||
@@ -9,145 +9,163 @@ import {
|
|||||||
buildAuthorizeUrl,
|
buildAuthorizeUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
} from '../../server/utils/oidc.js'
|
} from '../../server/utils/oidc.js'
|
||||||
|
import { withTemporaryEnv } from '../helpers/env.js'
|
||||||
|
|
||||||
describe('oidc', () => {
|
describe('oidc', () => {
|
||||||
describe('constantTimeCompare', () => {
|
describe('constantTimeCompare', () => {
|
||||||
it('returns true for equal strings', () => {
|
it.each([
|
||||||
expect(constantTimeCompare('abc', 'abc')).toBe(true)
|
[['abc', 'abc'], true],
|
||||||
})
|
[['abc', 'abd'], false],
|
||||||
it('returns false for different strings', () => {
|
[['ab', 'abc'], false],
|
||||||
expect(constantTimeCompare('abc', 'abd')).toBe(false)
|
[['a', 1], false],
|
||||||
})
|
])('compares %j -> %s', ([a, b], expected) => {
|
||||||
it('returns false for different length', () => {
|
expect(constantTimeCompare(a, b)).toBe(expected)
|
||||||
expect(constantTimeCompare('ab', 'abc')).toBe(false)
|
|
||||||
})
|
|
||||||
it('returns false for non-strings', () => {
|
|
||||||
expect(constantTimeCompare('a', 1)).toBe(false)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateRedirectPath', () => {
|
describe('validateRedirectPath', () => {
|
||||||
it('returns path for valid same-origin path', () => {
|
it.each([
|
||||||
expect(validateRedirectPath('/')).toBe('/')
|
['/', '/'],
|
||||||
expect(validateRedirectPath('/feeds')).toBe('/feeds')
|
['/feeds', '/feeds'],
|
||||||
expect(validateRedirectPath('/feeds?foo=1')).toBe('/feeds?foo=1')
|
['/feeds?foo=1', '/feeds?foo=1'],
|
||||||
})
|
['//evil.com', '/'],
|
||||||
it('returns / for path starting with //', () => {
|
['', '/'],
|
||||||
expect(validateRedirectPath('//evil.com')).toBe('/')
|
[null, '/'],
|
||||||
})
|
['/foo//bar', '/'],
|
||||||
it('returns / for non-string or empty', () => {
|
])('validates %s -> %s', (input, expected) => {
|
||||||
expect(validateRedirectPath('')).toBe('/')
|
expect(validateRedirectPath(input)).toBe(expected)
|
||||||
expect(validateRedirectPath(null)).toBe('/')
|
|
||||||
})
|
|
||||||
it('returns / for path containing //', () => {
|
|
||||||
expect(validateRedirectPath('/foo//bar')).toBe('/')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('createOidcParams', () => {
|
describe('createOidcParams', () => {
|
||||||
it('returns state, nonce, and codeVerifier', () => {
|
it('returns state, nonce, and codeVerifier', () => {
|
||||||
const p = createOidcParams()
|
const params = createOidcParams()
|
||||||
expect(p).toHaveProperty('state')
|
expect(params).toMatchObject({
|
||||||
expect(p).toHaveProperty('nonce')
|
state: expect.any(String),
|
||||||
expect(p).toHaveProperty('codeVerifier')
|
nonce: expect.any(String),
|
||||||
expect(typeof p.state).toBe('string')
|
codeVerifier: expect.any(String),
|
||||||
expect(typeof p.nonce).toBe('string')
|
})
|
||||||
expect(typeof p.codeVerifier).toBe('string')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getCodeChallenge', () => {
|
describe('getCodeChallenge', () => {
|
||||||
it('returns a string for a verifier', async () => {
|
it('returns a string for a verifier', async () => {
|
||||||
const p = createOidcParams()
|
const { codeVerifier } = createOidcParams()
|
||||||
const challenge = await getCodeChallenge(p.codeVerifier)
|
const challenge = await getCodeChallenge(codeVerifier)
|
||||||
expect(typeof challenge).toBe('string')
|
expect(challenge).toMatch(/^[\w-]+$/)
|
||||||
expect(challenge.length).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getOidcRedirectUri', () => {
|
describe('getOidcRedirectUri', () => {
|
||||||
const origEnv = process.env
|
it('returns URL ending with callback path when env is default', () => {
|
||||||
|
withTemporaryEnv(
|
||||||
afterEach(() => {
|
{
|
||||||
process.env = origEnv
|
OIDC_REDIRECT_URI: undefined,
|
||||||
|
OPENID_REDIRECT_URI: undefined,
|
||||||
|
NUXT_APP_URL: undefined,
|
||||||
|
APP_URL: undefined,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
expect(getOidcRedirectUri()).toMatch(/\/api\/auth\/oidc\/callback$/)
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a URL ending with callback path when env is default', () => {
|
it.each([
|
||||||
delete process.env.OIDC_REDIRECT_URI
|
[{ OIDC_REDIRECT_URI: ' https://app.example.com/oidc/cb ' }, 'https://app.example.com/oidc/cb'],
|
||||||
delete process.env.OPENID_REDIRECT_URI
|
[
|
||||||
delete process.env.NUXT_APP_URL
|
{ OIDC_REDIRECT_URI: undefined, OPENID_REDIRECT_URI: undefined, NUXT_APP_URL: 'https://myapp.example.com/' },
|
||||||
delete process.env.APP_URL
|
'https://myapp.example.com/api/auth/oidc/callback',
|
||||||
const uri = getOidcRedirectUri()
|
],
|
||||||
expect(uri).toMatch(/\/api\/auth\/oidc\/callback$/)
|
[
|
||||||
})
|
{
|
||||||
|
OIDC_REDIRECT_URI: undefined,
|
||||||
it('returns explicit OIDC_REDIRECT_URI when set', () => {
|
OPENID_REDIRECT_URI: undefined,
|
||||||
process.env.OIDC_REDIRECT_URI = ' https://app.example.com/oidc/cb '
|
NUXT_APP_URL: undefined,
|
||||||
const uri = getOidcRedirectUri()
|
APP_URL: 'https://app.example.com',
|
||||||
expect(uri).toBe('https://app.example.com/oidc/cb')
|
},
|
||||||
})
|
'https://app.example.com/api/auth/oidc/callback',
|
||||||
|
],
|
||||||
it('returns URL from NUXT_APP_URL when set and no explicit redirect', () => {
|
])('returns correct URI for env: %j', (env, expected) => {
|
||||||
delete process.env.OIDC_REDIRECT_URI
|
withTemporaryEnv(env, () => {
|
||||||
delete process.env.OPENID_REDIRECT_URI
|
expect(getOidcRedirectUri()).toBe(expected)
|
||||||
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', () => {
|
describe('getOidcConfig', () => {
|
||||||
const origEnv = process.env
|
it.each([
|
||||||
|
[{ OIDC_ISSUER: undefined, OIDC_CLIENT_ID: undefined, OIDC_CLIENT_SECRET: undefined }],
|
||||||
beforeEach(() => {
|
[{ OIDC_ISSUER: 'https://idp.example.com', OIDC_CLIENT_ID: 'client', OIDC_CLIENT_SECRET: undefined }],
|
||||||
process.env = { ...origEnv }
|
])('returns null when OIDC vars missing or incomplete: %j', async (env) => {
|
||||||
})
|
withTemporaryEnv(env, async () => {
|
||||||
|
expect(await getOidcConfig()).toBeNull()
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when only some OIDC env vars set', async () => {
|
|
||||||
process.env.OIDC_ISSUER = 'https://idp.example.com'
|
|
||||||
process.env.OIDC_CLIENT_ID = 'client'
|
|
||||||
delete process.env.OIDC_CLIENT_SECRET
|
|
||||||
const config = await getOidcConfig()
|
|
||||||
expect(config).toBeNull()
|
|
||||||
delete process.env.OIDC_ISSUER
|
|
||||||
delete process.env.OIDC_CLIENT_ID
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildAuthorizeUrl', () => {
|
describe('buildAuthorizeUrl', () => {
|
||||||
it('is a function that accepts config and params', () => {
|
it('is a function that accepts config and params', () => {
|
||||||
expect(typeof buildAuthorizeUrl).toBe('function')
|
expect(buildAuthorizeUrl).toBeInstanceOf(Function)
|
||||||
expect(buildAuthorizeUrl.length).toBe(2)
|
expect(buildAuthorizeUrl.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls oidc.buildAuthorizationUrl with valid config', async () => {
|
||||||
|
withTemporaryEnv(
|
||||||
|
{
|
||||||
|
OIDC_ISSUER: 'https://accounts.google.com',
|
||||||
|
OIDC_CLIENT_ID: 'test-client',
|
||||||
|
OIDC_CLIENT_SECRET: 'test-secret',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig()
|
||||||
|
if (config) {
|
||||||
|
const result = buildAuthorizeUrl(config, createOidcParams())
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Discovery failures are acceptable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getOidcConfig caching', () => {
|
||||||
|
it('caches config when called multiple times with same issuer', async () => {
|
||||||
|
withTemporaryEnv(
|
||||||
|
{
|
||||||
|
OIDC_ISSUER: 'https://accounts.google.com',
|
||||||
|
OIDC_CLIENT_ID: 'test-client',
|
||||||
|
OIDC_CLIENT_SECRET: 'test-secret',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const config1 = await getOidcConfig()
|
||||||
|
if (config1) {
|
||||||
|
const config2 = await getOidcConfig()
|
||||||
|
expect(config2).toBeDefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Network/discovery failures are acceptable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('exchangeCode', () => {
|
describe('exchangeCode', () => {
|
||||||
it('rejects when grant fails', async () => {
|
it('rejects when grant fails', async () => {
|
||||||
const config = {}
|
await expect(
|
||||||
const currentUrl = 'https://app/api/auth/oidc/callback?code=abc&state=s'
|
exchangeCode({}, 'https://app/api/auth/oidc/callback?code=abc&state=s', {
|
||||||
const checks = { state: 's', nonce: 'n', codeVerifier: 'v' }
|
state: 's',
|
||||||
await expect(exchangeCode(config, currentUrl, checks)).rejects.toBeDefined()
|
nonce: 'n',
|
||||||
|
codeVerifier: 'v',
|
||||||
|
}),
|
||||||
|
).rejects.toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { hashPassword, verifyPassword } from '../../server/utils/password.js'
|
import { hashPassword, verifyPassword } from '../../server/utils/password.js'
|
||||||
|
|
||||||
describe('password', () => {
|
describe('password', () => {
|
||||||
it('hashes and verifies', () => {
|
it('hashes and verifies password', () => {
|
||||||
const password = 'secret123'
|
const password = 'secret123'
|
||||||
const stored = hashPassword(password)
|
const stored = hashPassword(password)
|
||||||
expect(stored).toContain(':')
|
expect(stored).toContain(':')
|
||||||
@@ -14,8 +14,10 @@ describe('password', () => {
|
|||||||
expect(verifyPassword('wrong', stored)).toBe(false)
|
expect(verifyPassword('wrong', stored)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid stored format', () => {
|
it.each([
|
||||||
expect(verifyPassword('a', '')).toBe(false)
|
['a', ''],
|
||||||
expect(verifyPassword('a', 'nocolon')).toBe(false)
|
['a', 'nocolon'],
|
||||||
|
])('rejects invalid stored format: password=%s, stored=%s', (password, stored) => {
|
||||||
|
expect(verifyPassword(password, stored)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { POI_ICON_TYPES } from '../../server/utils/poiConstants.js'
|
import { POI_ICON_TYPES } from '../../server/utils/validation.js'
|
||||||
|
|
||||||
describe('poiConstants', () => {
|
describe('poiConstants', () => {
|
||||||
it('exports POI_ICON_TYPES as frozen array', () => {
|
it('exports POI_ICON_TYPES as frozen array', () => {
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
|
|
||||||
import {
|
|
||||||
getDeviceById,
|
|
||||||
getAllDevices,
|
|
||||||
createDevice,
|
|
||||||
updateDevice,
|
|
||||||
getUserById,
|
|
||||||
getUserByIdentifier,
|
|
||||||
createUser,
|
|
||||||
updateUser,
|
|
||||||
getPoiById,
|
|
||||||
getAllPois,
|
|
||||||
createPoi,
|
|
||||||
updatePoi,
|
|
||||||
getSessionById,
|
|
||||||
createDbSession,
|
|
||||||
deleteSession,
|
|
||||||
} from '../../server/utils/queries.js'
|
|
||||||
|
|
||||||
describe('queries', () => {
|
|
||||||
let db
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
setDbPathForTest(':memory:')
|
|
||||||
db = await getDb()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
setDbPathForTest(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('device queries', () => {
|
|
||||||
it('getDeviceById returns null for non-existent device', async () => {
|
|
||||||
const device = await getDeviceById(db, 'non-existent')
|
|
||||||
expect(device).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createDevice and getDeviceById work together', async () => {
|
|
||||||
const deviceData = {
|
|
||||||
name: 'Test Device',
|
|
||||||
device_type: 'traffic',
|
|
||||||
vendor: 'Test Vendor',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: 'https://example.com/stream',
|
|
||||||
source_type: 'mjpeg',
|
|
||||||
config: null,
|
|
||||||
}
|
|
||||||
const created = await createDevice(db, deviceData)
|
|
||||||
expect(created).toBeDefined()
|
|
||||||
expect(created.name).toBe('Test Device')
|
|
||||||
|
|
||||||
const retrieved = await getDeviceById(db, created.id)
|
|
||||||
expect(retrieved).toBeDefined()
|
|
||||||
expect(retrieved.name).toBe('Test Device')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createDevice handles vendor null', async () => {
|
|
||||||
const deviceData = {
|
|
||||||
name: 'Test',
|
|
||||||
device_type: 'feed',
|
|
||||||
vendor: null,
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: '',
|
|
||||||
source_type: 'mjpeg',
|
|
||||||
config: null,
|
|
||||||
}
|
|
||||||
const created = await createDevice(db, deviceData)
|
|
||||||
expect(created.vendor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createDevice handles all optional fields', async () => {
|
|
||||||
const deviceData = {
|
|
||||||
name: 'Full Device',
|
|
||||||
device_type: 'traffic',
|
|
||||||
vendor: 'Vendor Name',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: 'https://example.com/stream',
|
|
||||||
source_type: 'hls',
|
|
||||||
config: '{"key":"value"}',
|
|
||||||
}
|
|
||||||
const created = await createDevice(db, deviceData)
|
|
||||||
expect(created.name).toBe('Full Device')
|
|
||||||
expect(created.vendor).toBe('Vendor Name')
|
|
||||||
expect(created.stream_url).toBe('https://example.com/stream')
|
|
||||||
expect(created.source_type).toBe('hls')
|
|
||||||
expect(created.config).toBe('{"key":"value"}')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getAllDevices returns all devices', async () => {
|
|
||||||
await createDevice(db, {
|
|
||||||
name: 'Device 1',
|
|
||||||
device_type: 'feed',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: '',
|
|
||||||
source_type: 'mjpeg',
|
|
||||||
config: null,
|
|
||||||
})
|
|
||||||
await createDevice(db, {
|
|
||||||
name: 'Device 2',
|
|
||||||
device_type: 'traffic',
|
|
||||||
lat: 41.7128,
|
|
||||||
lng: -75.0060,
|
|
||||||
stream_url: '',
|
|
||||||
source_type: 'hls',
|
|
||||||
config: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const devices = await getAllDevices(db)
|
|
||||||
expect(devices).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateDevice updates device fields', async () => {
|
|
||||||
const created = await createDevice(db, {
|
|
||||||
name: 'Original',
|
|
||||||
device_type: 'feed',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: '',
|
|
||||||
source_type: 'mjpeg',
|
|
||||||
config: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await updateDevice(db, created.id, {
|
|
||||||
name: 'Updated',
|
|
||||||
lat: 41.7128,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updated.name).toBe('Updated')
|
|
||||||
expect(updated.lat).toBe(41.7128)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateDevice returns existing device when no updates', async () => {
|
|
||||||
const created = await createDevice(db, {
|
|
||||||
name: 'Test',
|
|
||||||
device_type: 'feed',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: '',
|
|
||||||
source_type: 'mjpeg',
|
|
||||||
config: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await updateDevice(db, created.id, {})
|
|
||||||
expect(result.id).toBe(created.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('user queries', () => {
|
|
||||||
it('getUserById returns null for non-existent user', async () => {
|
|
||||||
const user = await getUserById(db, 'non-existent')
|
|
||||||
expect(user).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createUser and getUserById work together', async () => {
|
|
||||||
const userData = {
|
|
||||||
identifier: 'testuser',
|
|
||||||
password_hash: 'hash123',
|
|
||||||
role: 'admin',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
auth_provider: 'local',
|
|
||||||
}
|
|
||||||
const created = await createUser(db, userData)
|
|
||||||
expect(created).toBeDefined()
|
|
||||||
expect(created.identifier).toBe('testuser')
|
|
||||||
|
|
||||||
const retrieved = await getUserById(db, created.id)
|
|
||||||
expect(retrieved).toBeDefined()
|
|
||||||
expect(retrieved.identifier).toBe('testuser')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createUser defaults auth_provider to local', async () => {
|
|
||||||
const userData = {
|
|
||||||
identifier: 'testuser2',
|
|
||||||
password_hash: 'hash',
|
|
||||||
role: 'member',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
const created = await createUser(db, userData)
|
|
||||||
expect(created.auth_provider).toBe('local')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createUser handles oidc fields', async () => {
|
|
||||||
const userData = {
|
|
||||||
identifier: 'oidcuser',
|
|
||||||
password_hash: null,
|
|
||||||
role: 'member',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
auth_provider: 'oidc',
|
|
||||||
oidc_issuer: 'https://example.com',
|
|
||||||
oidc_sub: 'sub123',
|
|
||||||
}
|
|
||||||
const created = await createUser(db, userData)
|
|
||||||
expect(created.auth_provider).toBe('oidc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getUserByIdentifier finds user by identifier', async () => {
|
|
||||||
await createUser(db, {
|
|
||||||
identifier: 'findme',
|
|
||||||
password_hash: 'hash',
|
|
||||||
role: 'member',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
auth_provider: 'local',
|
|
||||||
})
|
|
||||||
|
|
||||||
const user = await getUserByIdentifier(db, 'findme')
|
|
||||||
expect(user).toBeDefined()
|
|
||||||
expect(user.identifier).toBe('findme')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateUser updates user fields', async () => {
|
|
||||||
const created = await createUser(db, {
|
|
||||||
identifier: 'original',
|
|
||||||
password_hash: 'hash',
|
|
||||||
role: 'member',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
auth_provider: 'local',
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await updateUser(db, created.id, {
|
|
||||||
role: 'admin',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updated.role).toBe('admin')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateUser returns existing user when no updates', async () => {
|
|
||||||
const created = await createUser(db, {
|
|
||||||
identifier: 'test',
|
|
||||||
password_hash: 'hash',
|
|
||||||
role: 'member',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
auth_provider: 'local',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await updateUser(db, created.id, {})
|
|
||||||
expect(result.id).toBe(created.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POI queries', () => {
|
|
||||||
it('getPoiById returns null for non-existent POI', async () => {
|
|
||||||
const poi = await getPoiById(db, 'non-existent')
|
|
||||||
expect(poi).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createPoi and getPoiById work together', async () => {
|
|
||||||
const poiData = {
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
label: 'Test POI',
|
|
||||||
icon_type: 'flag',
|
|
||||||
}
|
|
||||||
const created = await createPoi(db, poiData)
|
|
||||||
expect(created).toBeDefined()
|
|
||||||
expect(created.label).toBe('Test POI')
|
|
||||||
|
|
||||||
const retrieved = await getPoiById(db, created.id)
|
|
||||||
expect(retrieved).toBeDefined()
|
|
||||||
expect(retrieved.label).toBe('Test POI')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createPoi defaults label and icon_type', async () => {
|
|
||||||
const poiData = {
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
}
|
|
||||||
const created = await createPoi(db, poiData)
|
|
||||||
expect(created.label).toBe('')
|
|
||||||
expect(created.icon_type).toBe('pin')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getAllPois returns all POIs', async () => {
|
|
||||||
await createPoi(db, { lat: 40.7128, lng: -74.0060, label: 'POI 1' })
|
|
||||||
await createPoi(db, { lat: 41.7128, lng: -75.0060, label: 'POI 2' })
|
|
||||||
|
|
||||||
const pois = await getAllPois(db)
|
|
||||||
expect(pois).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updatePoi updates POI fields', async () => {
|
|
||||||
const created = await createPoi(db, {
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
label: 'Original',
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await updatePoi(db, created.id, {
|
|
||||||
label: 'Updated',
|
|
||||||
lat: 41.7128,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updated.label).toBe('Updated')
|
|
||||||
expect(updated.lat).toBe(41.7128)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updatePoi returns existing POI when no updates', async () => {
|
|
||||||
const created = await createPoi(db, {
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
label: 'Test',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await updatePoi(db, created.id, {})
|
|
||||||
expect(result.id).toBe(created.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('session queries', () => {
|
|
||||||
it('getSessionById returns null for non-existent session', async () => {
|
|
||||||
const session = await getSessionById(db, 'non-existent')
|
|
||||||
expect(session).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createDbSession and getSessionById work together', async () => {
|
|
||||||
const sessionData = {
|
|
||||||
id: 'session-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
expires_at: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
}
|
|
||||||
await createDbSession(db, sessionData)
|
|
||||||
|
|
||||||
const retrieved = await getSessionById(db, 'session-1')
|
|
||||||
expect(retrieved).toBeDefined()
|
|
||||||
expect(retrieved.user_id).toBe('user-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deleteSession removes session', async () => {
|
|
||||||
await createDbSession(db, {
|
|
||||||
id: 'session-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
expires_at: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await deleteSession(db, 'session-1')
|
|
||||||
|
|
||||||
const retrieved = await getSessionById(db, 'session-1')
|
|
||||||
expect(retrieved).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,95 +1,71 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from '../../server/utils/sanitize.js'
|
import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from '../../server/utils/validation.js'
|
||||||
|
|
||||||
describe('sanitize', () => {
|
describe('sanitize', () => {
|
||||||
describe('sanitizeString', () => {
|
describe('sanitizeString', () => {
|
||||||
it('trims whitespace', () => {
|
it.each([
|
||||||
expect(sanitizeString(' test ')).toBe('test')
|
[' test ', 'test'],
|
||||||
expect(sanitizeString('\n\ttest\n\t')).toBe('test')
|
['\n\ttest\n\t', 'test'],
|
||||||
|
['valid string', 'valid string'],
|
||||||
|
['test123', 'test123'],
|
||||||
|
])('trims whitespace and preserves valid: %s -> %s', (input, expected) => {
|
||||||
|
expect(sanitizeString(input)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns empty string for non-string input', () => {
|
it.each([null, undefined, 123, {}])('returns empty for non-string: %s', (input) => {
|
||||||
expect(sanitizeString(null)).toBe('')
|
expect(sanitizeString(input)).toBe('')
|
||||||
expect(sanitizeString(undefined)).toBe('')
|
|
||||||
expect(sanitizeString(123)).toBe('')
|
|
||||||
expect(sanitizeString({})).toBe('')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('truncates strings exceeding max length', () => {
|
it('truncates strings exceeding max length', () => {
|
||||||
const longString = 'a'.repeat(2000)
|
expect(sanitizeString('a'.repeat(2000), 1000).length).toBe(1000)
|
||||||
expect(sanitizeString(longString, 1000).length).toBe(1000)
|
expect(sanitizeString('a'.repeat(2000)).length).toBe(1000)
|
||||||
})
|
|
||||||
|
|
||||||
it('uses default max length', () => {
|
|
||||||
const longString = 'a'.repeat(2000)
|
|
||||||
expect(sanitizeString(longString).length).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves valid strings', () => {
|
|
||||||
expect(sanitizeString('valid string')).toBe('valid string')
|
|
||||||
expect(sanitizeString('test123')).toBe('test123')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sanitizeIdentifier', () => {
|
describe('sanitizeIdentifier', () => {
|
||||||
it('accepts valid identifiers', () => {
|
it.each([
|
||||||
expect(sanitizeIdentifier('test123')).toBe('test123')
|
['test123', 'test123'],
|
||||||
expect(sanitizeIdentifier('test_user')).toBe('test_user')
|
['test_user', 'test_user'],
|
||||||
expect(sanitizeIdentifier('Test123')).toBe('Test123')
|
['Test123', 'Test123'],
|
||||||
expect(sanitizeIdentifier('_test')).toBe('_test')
|
['_test', '_test'],
|
||||||
|
[' test123 ', 'test123'],
|
||||||
|
])('accepts valid identifier: %s -> %s', (input, expected) => {
|
||||||
|
expect(sanitizeIdentifier(input)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid characters', () => {
|
it.each([
|
||||||
expect(sanitizeIdentifier('test-user')).toBe('')
|
['test-user'],
|
||||||
expect(sanitizeIdentifier('test.user')).toBe('')
|
['test.user'],
|
||||||
expect(sanitizeIdentifier('test user')).toBe('')
|
['test user'],
|
||||||
expect(sanitizeIdentifier('test@user')).toBe('')
|
['test@user'],
|
||||||
|
[''],
|
||||||
|
[' '],
|
||||||
|
['a'.repeat(256)],
|
||||||
|
])('rejects invalid identifier: %s', (input) => {
|
||||||
|
expect(sanitizeIdentifier(input)).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('trims whitespace', () => {
|
it.each([null, undefined, 123])('returns empty for non-string: %s', (input) => {
|
||||||
expect(sanitizeIdentifier(' test123 ')).toBe('test123')
|
expect(sanitizeIdentifier(input)).toBe('')
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for non-string input', () => {
|
|
||||||
expect(sanitizeIdentifier(null)).toBe('')
|
|
||||||
expect(sanitizeIdentifier(undefined)).toBe('')
|
|
||||||
expect(sanitizeIdentifier(123)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty strings', () => {
|
|
||||||
expect(sanitizeIdentifier('')).toBe('')
|
|
||||||
expect(sanitizeIdentifier(' ')).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects strings exceeding max length', () => {
|
|
||||||
const longId = 'a'.repeat(256)
|
|
||||||
expect(sanitizeIdentifier(longId)).toBe('')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sanitizeLabel', () => {
|
describe('sanitizeLabel', () => {
|
||||||
it('trims whitespace', () => {
|
it.each([
|
||||||
expect(sanitizeLabel(' test label ')).toBe('test label')
|
[' test label ', 'test label'],
|
||||||
|
['Valid Label', 'Valid Label'],
|
||||||
|
['Test 123', 'Test 123'],
|
||||||
|
])('trims whitespace and preserves valid: %s -> %s', (input, expected) => {
|
||||||
|
expect(sanitizeLabel(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([null, undefined])('returns empty for non-string: %s', (input) => {
|
||||||
|
expect(sanitizeLabel(input)).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('truncates long labels', () => {
|
it('truncates long labels', () => {
|
||||||
const longLabel = 'a'.repeat(2000)
|
expect(sanitizeLabel('a'.repeat(2000), 500).length).toBe(500)
|
||||||
expect(sanitizeLabel(longLabel, 500).length).toBe(500)
|
expect(sanitizeLabel('a'.repeat(2000)).length).toBe(1000)
|
||||||
})
|
|
||||||
|
|
||||||
it('uses default max length', () => {
|
|
||||||
const longLabel = 'a'.repeat(2000)
|
|
||||||
expect(sanitizeLabel(longLabel).length).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for non-string input', () => {
|
|
||||||
expect(sanitizeLabel(null)).toBe('')
|
|
||||||
expect(sanitizeLabel(undefined)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves valid labels', () => {
|
|
||||||
expect(sanitizeLabel('Valid Label')).toBe('Valid Label')
|
|
||||||
expect(sanitizeLabel('Test 123')).toBe('Test 123')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,39 +1,17 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { getSessionMaxAgeDays } from '../../server/utils/session.js'
|
import { getSessionMaxAgeDays } from '../../server/utils/constants.js'
|
||||||
|
import { withTemporaryEnv } from '../helpers/env.js'
|
||||||
|
|
||||||
describe('session', () => {
|
describe('session', () => {
|
||||||
const origEnv = process.env
|
it.each([
|
||||||
|
[{ SESSION_MAX_AGE_DAYS: undefined }, 7],
|
||||||
beforeEach(() => {
|
[{ SESSION_MAX_AGE_DAYS: 'invalid' }, 7],
|
||||||
process.env = { ...origEnv }
|
[{ SESSION_MAX_AGE_DAYS: '0' }, 1],
|
||||||
})
|
[{ SESSION_MAX_AGE_DAYS: '400' }, 365],
|
||||||
|
[{ SESSION_MAX_AGE_DAYS: '14' }, 14],
|
||||||
afterEach(() => {
|
])('returns correct days for SESSION_MAX_AGE_DAYS=%s', (env, expected) => {
|
||||||
process.env = origEnv
|
withTemporaryEnv(env, () => {
|
||||||
})
|
expect(getSessionMaxAgeDays()).toBe(expected)
|
||||||
|
})
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -153,4 +153,70 @@ describe('shutdown', () => {
|
|||||||
await graceful()
|
await graceful()
|
||||||
expect(exitCalls.length).toBeGreaterThan(0)
|
expect(exitCalls.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('covers graceful catch block when executeCleanup throws', async () => {
|
||||||
|
// The catch block in graceful() handles errors from executeCleanup()
|
||||||
|
// Since executeCleanup() catches errors internally, we need to test
|
||||||
|
// a scenario where executeCleanup itself throws (not just cleanup functions)
|
||||||
|
// This is hard to test directly, but we can verify the error handling path exists
|
||||||
|
const originalClearTimeout = clearTimeout
|
||||||
|
const clearTimeoutCalls = []
|
||||||
|
global.clearTimeout = vi.fn((id) => {
|
||||||
|
clearTimeoutCalls.push(id)
|
||||||
|
originalClearTimeout(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register cleanup that throws - executeCleanup catches this internally
|
||||||
|
registerCleanup(async () => {
|
||||||
|
throw new Error('Execute cleanup error')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The graceful function should handle this and exit with code 0 (not 1)
|
||||||
|
// because executeCleanup catches errors internally
|
||||||
|
await graceful()
|
||||||
|
|
||||||
|
// Should exit successfully (code 0) because executeCleanup handles errors internally
|
||||||
|
expect(exitCalls).toContain(0)
|
||||||
|
expect(clearTimeoutCalls.length).toBeGreaterThan(0)
|
||||||
|
global.clearTimeout = originalClearTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
it('covers signal handler error path', async () => {
|
||||||
|
const handlers = {}
|
||||||
|
const originalOn = process.on
|
||||||
|
const originalExit = process.exit
|
||||||
|
const originalConsoleError = console.error
|
||||||
|
const errorLogs = []
|
||||||
|
console.error = vi.fn((...args) => {
|
||||||
|
errorLogs.push(args.join(' '))
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on = vi.fn((signal, handler) => {
|
||||||
|
handlers[signal] = handler
|
||||||
|
})
|
||||||
|
|
||||||
|
initShutdownHandlers()
|
||||||
|
|
||||||
|
// Simulate graceful() rejecting in the signal handler
|
||||||
|
const gracefulPromise = Promise.reject(new Error('Graceful shutdown error'))
|
||||||
|
handlers.SIGTERM = () => {
|
||||||
|
gracefulPromise.catch((err) => {
|
||||||
|
console.error('[shutdown] Error in graceful shutdown:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger the handler
|
||||||
|
handlers.SIGTERM()
|
||||||
|
|
||||||
|
// Wait a bit for async operations
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
expect(errorLogs.some(log => log.includes('Error in graceful shutdown'))).toBe(true)
|
||||||
|
expect(exitCalls).toContain(1)
|
||||||
|
|
||||||
|
process.on = originalOn
|
||||||
|
process.exit = originalExit
|
||||||
|
console.error = originalConsoleError
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,33 +20,56 @@ describe('validation', () => {
|
|||||||
source_type: 'mjpeg',
|
source_type: 'mjpeg',
|
||||||
})
|
})
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data).toBeDefined()
|
|
||||||
expect(result.data.device_type).toBe('traffic')
|
expect(result.data.device_type).toBe('traffic')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid coordinates', () => {
|
it.each([
|
||||||
const result = validateDevice({
|
[{ name: 'Test', lat: 'invalid', lng: -74.0060 }, 'lat and lng required as finite numbers'],
|
||||||
name: 'Test',
|
[null, 'body required'],
|
||||||
lat: 'invalid',
|
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||||
lng: -74.0060,
|
const result = validateDevice(input)
|
||||||
})
|
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('lat and lng required as finite numbers')
|
expect(result.errors).toContain(errorMsg)
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects non-object input', () => {
|
|
||||||
const result = validateDevice(null)
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.errors).toContain('body required')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults device_type to feed', () => {
|
it('defaults device_type to feed', () => {
|
||||||
|
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.device_type).toBe('feed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults stream_url to empty string', () => {
|
||||||
|
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.stream_url).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults invalid source_type to mjpeg', () => {
|
||||||
const result = validateDevice({
|
const result = validateDevice({
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
lat: 40.7128,
|
lat: 40.7128,
|
||||||
lng: -74.0060,
|
lng: -74.0060,
|
||||||
|
source_type: 'invalid',
|
||||||
})
|
})
|
||||||
expect(result.data.device_type).toBe('feed')
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.source_type).toBe('mjpeg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ name: 'Test', lat: 40.7128, lng: -74.0060 }, null],
|
||||||
|
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: { key: 'value' } }, '{"key":"value"}'],
|
||||||
|
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: '{"key":"value"}' }, '{"key":"value"}'],
|
||||||
|
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: null }, null],
|
||||||
|
])('handles config: %j -> %s', (input, expected) => {
|
||||||
|
const result = validateDevice(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.config).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults vendor to null', () => {
|
||||||
|
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.vendor).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,8 +77,7 @@ describe('validation', () => {
|
|||||||
it('validates partial updates', () => {
|
it('validates partial updates', () => {
|
||||||
const result = validateUpdateDevice({ name: 'Updated', lat: 40.7128 })
|
const result = validateUpdateDevice({ name: 'Updated', lat: 40.7128 })
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.name).toBe('Updated')
|
expect(result.data).toMatchObject({ name: 'Updated', lat: 40.7128 })
|
||||||
expect(result.data.lat).toBe(40.7128)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows empty updates', () => {
|
it('allows empty updates', () => {
|
||||||
@@ -64,34 +86,78 @@ describe('validation', () => {
|
|||||||
expect(Object.keys(result.data).length).toBe(0)
|
expect(Object.keys(result.data).length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid device_type', () => {
|
it.each([
|
||||||
const result = validateUpdateDevice({ device_type: 'invalid' })
|
[{ device_type: 'invalid' }, 'Invalid device_type'],
|
||||||
|
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||||
|
const result = validateUpdateDevice(input)
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('Invalid device_type')
|
expect(result.errors).toContain(errorMsg)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles device_type undefined', () => {
|
it.each([
|
||||||
|
[{ name: 'Test' }, undefined],
|
||||||
|
[{ device_type: 'traffic' }, 'traffic'],
|
||||||
|
])('handles device_type: %j -> %s', (input, expected) => {
|
||||||
|
const result = validateUpdateDevice(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.device_type).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ vendor: null }, null],
|
||||||
|
[{ vendor: '' }, null],
|
||||||
|
[{ vendor: 'Test Vendor' }, 'Test Vendor'],
|
||||||
|
])('handles vendor: %j -> %s', (input, expected) => {
|
||||||
|
const result = validateUpdateDevice(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.vendor).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ config: { key: 'value' } }, '{"key":"value"}'],
|
||||||
|
[{ config: '{"key":"value"}' }, '{"key":"value"}'],
|
||||||
|
[{ config: null }, null],
|
||||||
|
[{ config: undefined }, undefined],
|
||||||
|
[{ name: 'Test' }, undefined],
|
||||||
|
])('handles config: %j', (input, expected) => {
|
||||||
|
const result = validateUpdateDevice(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.config).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles all field types', () => {
|
||||||
|
const result = validateUpdateDevice({
|
||||||
|
name: 'Test',
|
||||||
|
device_type: 'traffic',
|
||||||
|
vendor: 'Vendor',
|
||||||
|
lat: 40.7128,
|
||||||
|
lng: -74.0060,
|
||||||
|
stream_url: 'https://example.com',
|
||||||
|
source_type: 'hls',
|
||||||
|
config: { key: 'value' },
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data).toMatchObject({
|
||||||
|
name: 'Test',
|
||||||
|
device_type: 'traffic',
|
||||||
|
vendor: 'Vendor',
|
||||||
|
lat: 40.7128,
|
||||||
|
lng: -74.0060,
|
||||||
|
stream_url: 'https://example.com',
|
||||||
|
source_type: 'hls',
|
||||||
|
config: '{"key":"value"}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['source_type'],
|
||||||
|
['lat'],
|
||||||
|
['lng'],
|
||||||
|
['stream_url'],
|
||||||
|
])('handles %s undefined in updates', (field) => {
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
const result = validateUpdateDevice({ name: 'Test' })
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.device_type).toBeUndefined()
|
expect(result.data[field]).toBeUndefined()
|
||||||
})
|
|
||||||
|
|
||||||
it('handles vendor null', () => {
|
|
||||||
const result = validateUpdateDevice({ vendor: null })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles vendor empty string', () => {
|
|
||||||
const result = validateUpdateDevice({ vendor: '' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles vendor string', () => {
|
|
||||||
const result = validateUpdateDevice({ vendor: 'Test Vendor' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBe('Test Vendor')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,23 +172,13 @@ describe('validation', () => {
|
|||||||
expect(result.data.identifier).toBe('testuser')
|
expect(result.data.identifier).toBe('testuser')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects missing identifier', () => {
|
it.each([
|
||||||
const result = validateUser({
|
[{ password: 'password123', role: 'admin' }, 'identifier required'],
|
||||||
password: 'password123',
|
[{ identifier: 'testuser', password: 'password123', role: 'invalid' }, 'role must be admin, leader, or member'],
|
||||||
role: 'admin',
|
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||||
})
|
const result = validateUser(input)
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('identifier required')
|
expect(result.errors).toContain(errorMsg)
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid role', () => {
|
|
||||||
const result = validateUser({
|
|
||||||
identifier: 'testuser',
|
|
||||||
password: 'password123',
|
|
||||||
role: 'invalid',
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.errors).toContain('role must be admin, leader, or member')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,6 +194,26 @@ describe('validation', () => {
|
|||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('identifier cannot be empty')
|
expect(result.errors).toContain('identifier cannot be empty')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ password: '' }, undefined],
|
||||||
|
[{ password: undefined }, undefined],
|
||||||
|
[{ password: 'newpassword' }, 'newpassword'],
|
||||||
|
])('handles password: %j -> %s', (input, expected) => {
|
||||||
|
const result = validateUpdateUser(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.password).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['role'],
|
||||||
|
['identifier'],
|
||||||
|
['password'],
|
||||||
|
])('handles %s undefined', (field) => {
|
||||||
|
const result = validateUpdateUser({})
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data[field]).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validatePoi', () => {
|
describe('validatePoi', () => {
|
||||||
@@ -149,31 +225,35 @@ describe('validation', () => {
|
|||||||
iconType: 'flag',
|
iconType: 'flag',
|
||||||
})
|
})
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.lat).toBe(40.7128)
|
expect(result.data).toMatchObject({
|
||||||
|
lat: 40.7128,
|
||||||
|
lng: -74.0060,
|
||||||
|
label: 'Test POI',
|
||||||
|
icon_type: 'flag',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid coordinates', () => {
|
it('rejects invalid coordinates', () => {
|
||||||
const result = validatePoi({
|
const result = validatePoi({ lat: 'invalid', lng: -74.0060 })
|
||||||
lat: 'invalid',
|
|
||||||
lng: -74.0060,
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('lat and lng required as finite numbers')
|
expect(result.errors).toContain('lat and lng required as finite numbers')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ lat: 40.7128, lng: -74.0060 }, 'pin'],
|
||||||
|
[{ lat: 40.7128, lng: -74.0060, iconType: 'invalid' }, 'pin'],
|
||||||
|
])('defaults iconType to pin: %j -> %s', (input, expected) => {
|
||||||
|
const result = validatePoi(input)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.data.icon_type).toBe(expected)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateUpdatePoi', () => {
|
describe('validateUpdatePoi', () => {
|
||||||
it('validates partial updates', () => {
|
it('validates partial updates', () => {
|
||||||
const result = validateUpdatePoi({ label: 'Updated', lat: 40.7128 })
|
const result = validateUpdatePoi({ label: 'Updated', lat: 40.7128 })
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.label).toBe('Updated')
|
expect(result.data).toMatchObject({ label: 'Updated', lat: 40.7128 })
|
||||||
expect(result.data.lat).toBe(40.7128)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid iconType', () => {
|
|
||||||
const result = validateUpdatePoi({ iconType: 'invalid' })
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.errors).toContain('Invalid iconType')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows empty updates', () => {
|
it('allows empty updates', () => {
|
||||||
@@ -182,154 +262,16 @@ describe('validation', () => {
|
|||||||
expect(Object.keys(result.data).length).toBe(0)
|
expect(Object.keys(result.data).length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid lat', () => {
|
it.each([
|
||||||
const result = validateUpdatePoi({ lat: 'invalid' })
|
[{ iconType: 'invalid' }, 'Invalid iconType'],
|
||||||
|
[{ lat: 'invalid' }, 'lat must be a finite number'],
|
||||||
|
[{ lng: 'invalid' }, 'lng must be a finite number'],
|
||||||
|
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||||
|
const result = validateUpdatePoi(input)
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toContain('lat must be a finite number')
|
expect(result.errors).toContain(errorMsg)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects invalid lng', () => {
|
|
||||||
const result = validateUpdatePoi({ lng: 'invalid' })
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.errors).toContain('lng must be a finite number')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdateDevice', () => {
|
|
||||||
it('handles vendor null', () => {
|
|
||||||
const result = validateUpdateDevice({ vendor: null })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles vendor empty string', () => {
|
|
||||||
const result = validateUpdateDevice({ vendor: '' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config object', () => {
|
|
||||||
const result = validateUpdateDevice({ config: { key: 'value' } })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBe('{"key":"value"}')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config null', () => {
|
|
||||||
const result = validateUpdateDevice({ config: null })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config string', () => {
|
|
||||||
const result = validateUpdateDevice({ config: '{"key":"value"}' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBe('{"key":"value"}')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdateUser', () => {
|
|
||||||
it('handles empty password', () => {
|
|
||||||
const result = validateUpdateUser({ password: '' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.password).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles undefined password', () => {
|
|
||||||
const result = validateUpdateUser({ password: undefined })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.password).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates password when provided', () => {
|
|
||||||
const result = validateUpdateUser({ password: 'newpassword' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.password).toBe('newpassword')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateDevice', () => {
|
|
||||||
it('handles missing stream_url', () => {
|
|
||||||
const result = validateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.stream_url).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles invalid source_type', () => {
|
|
||||||
const result = validateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
source_type: 'invalid',
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.source_type).toBe('mjpeg')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validatePoi', () => {
|
|
||||||
it('defaults iconType to pin', () => {
|
|
||||||
const result = validatePoi({
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.icon_type).toBe('pin')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles invalid iconType', () => {
|
|
||||||
const result = validatePoi({
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
iconType: 'invalid',
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.icon_type).toBe('pin')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates valid POI with all fields', () => {
|
|
||||||
const result = validatePoi({
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
label: 'Test POI',
|
|
||||||
iconType: 'flag',
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.lat).toBe(40.7128)
|
|
||||||
expect(result.data.lng).toBe(-74.0060)
|
|
||||||
expect(result.data.label).toBe('Test POI')
|
|
||||||
expect(result.data.icon_type).toBe('flag')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdateDevice', () => {
|
|
||||||
it('handles all field types', () => {
|
|
||||||
const result = validateUpdateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
device_type: 'traffic',
|
|
||||||
vendor: 'Vendor',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
stream_url: 'https://example.com',
|
|
||||||
source_type: 'hls',
|
|
||||||
config: { key: 'value' },
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.name).toBe('Test')
|
|
||||||
expect(result.data.device_type).toBe('traffic')
|
|
||||||
expect(result.data.vendor).toBe('Vendor')
|
|
||||||
expect(result.data.lat).toBe(40.7128)
|
|
||||||
expect(result.data.lng).toBe(-74.0060)
|
|
||||||
expect(result.data.stream_url).toBe('https://example.com')
|
|
||||||
expect(result.data.source_type).toBe('hls')
|
|
||||||
expect(result.data.config).toBe('{"key":"value"}')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdatePoi', () => {
|
|
||||||
it('handles all field types', () => {
|
it('handles all field types', () => {
|
||||||
const result = validateUpdatePoi({
|
const result = validateUpdatePoi({
|
||||||
label: 'Updated',
|
label: 'Updated',
|
||||||
@@ -338,151 +280,23 @@ describe('validation', () => {
|
|||||||
lng: -75.0060,
|
lng: -75.0060,
|
||||||
})
|
})
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.label).toBe('Updated')
|
expect(result.data).toMatchObject({
|
||||||
expect(result.data.icon_type).toBe('waypoint')
|
label: 'Updated',
|
||||||
expect(result.data.lat).toBe(41.7128)
|
icon_type: 'waypoint',
|
||||||
expect(result.data.lng).toBe(-75.0060)
|
lat: 41.7128,
|
||||||
})
|
lng: -75.0060,
|
||||||
|
|
||||||
it('handles partial updates', () => {
|
|
||||||
const result = validateUpdatePoi({ label: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.label).toBe('Test')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateDevice edge cases', () => {
|
|
||||||
it('handles vendor undefined', () => {
|
|
||||||
const result = validateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
})
|
})
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.vendor).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles config as object', () => {
|
it.each([
|
||||||
const result = validateDevice({
|
['label'],
|
||||||
name: 'Test',
|
['icon_type'],
|
||||||
lat: 40.7128,
|
['lat'],
|
||||||
lng: -74.0060,
|
['lng'],
|
||||||
config: { key: 'value' },
|
])('handles %s undefined', (field) => {
|
||||||
})
|
const result = validateUpdatePoi({})
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true)
|
||||||
expect(result.data.config).toBe('{"key":"value"}')
|
expect(result.data[field]).toBeUndefined()
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config as string', () => {
|
|
||||||
const result = validateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
config: '{"key":"value"}',
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBe('{"key":"value"}')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config null', () => {
|
|
||||||
const result = validateDevice({
|
|
||||||
name: 'Test',
|
|
||||||
lat: 40.7128,
|
|
||||||
lng: -74.0060,
|
|
||||||
config: null,
|
|
||||||
})
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBe(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdateDevice edge cases', () => {
|
|
||||||
it('handles config null in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ config: null })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ config: undefined })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles source_type undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.source_type).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles lat undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.lat).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles lng undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.lng).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles stream_url undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.stream_url).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles config undefined in updates', () => {
|
|
||||||
const result = validateUpdateDevice({ name: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.config).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdateUser edge cases', () => {
|
|
||||||
it('handles role undefined', () => {
|
|
||||||
const result = validateUpdateUser({ identifier: 'test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.role).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles identifier undefined', () => {
|
|
||||||
const result = validateUpdateUser({ role: 'admin' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.identifier).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles password undefined', () => {
|
|
||||||
const result = validateUpdateUser({ role: 'admin' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.password).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('validateUpdatePoi edge cases', () => {
|
|
||||||
it('handles label undefined', () => {
|
|
||||||
const result = validateUpdatePoi({ lat: 40.7128 })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.label).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles iconType undefined', () => {
|
|
||||||
const result = validateUpdatePoi({ label: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.icon_type).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles lat undefined', () => {
|
|
||||||
const result = validateUpdatePoi({ label: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.lat).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles lng undefined', () => {
|
|
||||||
const result = validateUpdatePoi({ label: 'Test' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.data.lng).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user