refactor testing
Some checks failed
ci/woodpecker/pr/pr Pipeline failed

This commit is contained in:
Madison Grubb
2026-02-17 11:05:57 -05:00
parent b0e8dd7ad9
commit 1a566e2d80
57 changed files with 1127 additions and 1760 deletions

View File

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

View File

@@ -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' })
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 + '/'))
}

View File

@@ -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 + '/'))
}

View File

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

View File

@@ -21,3 +21,10 @@ export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) |
// Mediasoup
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
// 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
}

View File

@@ -1,7 +1,15 @@
import { XMLParser } from 'fast-xml-parser'
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')
/**
@@ -30,7 +38,7 @@ function readVarint(buf, offset) {
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
*/
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)
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
const bytesConsumed = 1 + bytesRead + length
@@ -44,7 +52,7 @@ export function parseTakStreamFrame(buf) {
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
*/
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)
if (idx === -1) return null
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length

View File

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

View File

@@ -2,7 +2,8 @@ import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
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'
// 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.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() {

View File

@@ -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',
},
}
}

View File

@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
const CACHE_TTL_MS = 60 * 60 * 1000
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() {
const explicit
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''

View File

@@ -1 +0,0 @@
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])

View File

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

View File

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

View File

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

View File

@@ -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 { 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']