test: expand coverage for core backend features

This commit is contained in:
itsrubberduck
2026-02-17 18:36:13 +01:00
parent d35930565e
commit db7b147e9c
5 changed files with 389 additions and 5 deletions

136
tests/server/auth.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
createAccessToken,
createRefreshToken,
hasAdminRole,
hashPassword,
requireUserSession,
resolveUserFromToken,
verifyPassword,
} from '~~/server/utils/auth'
import { User } from '~~/server/models/User'
function decodeJwtPayload(token: string) {
const parts = token.split('.')
assert.equal(parts.length, 3)
let payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const padding = payload.length % 4
if (padding) payload = payload.padEnd(payload.length + (4 - padding), '=')
return JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as Record<string, any>
}
describe('auth utils', () => {
it('hashes and verifies passwords', async () => {
const raw = 'OpenSquawk123!'
const hash = await hashPassword(raw)
assert.match(hash, /^[a-f0-9]+\.[a-f0-9]+$/i)
assert.equal(await verifyPassword(raw, hash), true)
assert.equal(await verifyPassword('wrong-password', hash), false)
})
it('creates access and refresh tokens with expected claims and ttl', () => {
process.env.JWT_SECRET = 'test-access-secret'
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret'
const user = {
_id: '507f1f77bcf86cd799439011',
email: 'pilot@example.com',
tokenVersion: 7,
role: 'user',
} as any
const access = createAccessToken(user)
const refresh = createRefreshToken(user)
const accessPayload = decodeJwtPayload(access)
const refreshPayload = decodeJwtPayload(refresh)
assert.equal(accessPayload.sub, user._id)
assert.equal(accessPayload.email, user.email)
assert.equal(accessPayload.version, 7)
assert.equal(accessPayload.exp - accessPayload.iat, 60 * 60 * 24)
assert.equal(refreshPayload.sub, user._id)
assert.equal(refreshPayload.type, 'refresh')
assert.equal(refreshPayload.version, 7)
assert.equal(refreshPayload.exp - refreshPayload.iat, 60 * 60 * 24 * 7)
})
it('detects admin and dev roles correctly', () => {
assert.equal(hasAdminRole({ role: 'admin' } as any), true)
assert.equal(hasAdminRole({ role: 'dev' } as any), true)
assert.equal(hasAdminRole({ role: 'user' } as any), false)
assert.equal(hasAdminRole(null), false)
})
it('resolves user from valid bearer access token', async () => {
process.env.JWT_SECRET = 'session-access-secret'
const dbUser = {
_id: '507f1f77bcf86cd799439011',
email: 'pilot@example.com',
tokenVersion: 4,
role: 'user',
} as any
const token = createAccessToken(dbUser)
const originalFindById = (User as any).findById
;(User as any).findById = async (id: string) => (id === dbUser._id ? dbUser : null)
try {
const event = {
node: {
req: {
headers: {
authorization: `Bearer ${token}`,
},
},
},
context: {},
} as any
const resolved = await resolveUserFromToken(event)
assert.equal(resolved, dbUser)
} finally {
;(User as any).findById = originalFindById
}
})
it('returns null for token version mismatch and requireUserSession throws 401', async () => {
process.env.JWT_SECRET = 'session-access-secret'
const tokenUser = {
_id: '507f1f77bcf86cd799439012',
email: 'pilot2@example.com',
tokenVersion: 1,
role: 'user',
} as any
const dbUser = { ...tokenUser, tokenVersion: 2 }
const token = createAccessToken(tokenUser)
const originalFindById = (User as any).findById
;(User as any).findById = async () => dbUser
try {
const event = {
node: {
req: {
headers: {
authorization: `Bearer ${token}`,
},
},
},
context: {},
} as any
const resolved = await resolveUserFromToken(event)
assert.equal(resolved, null)
await assert.rejects(
() => requireUserSession(event),
(error: any) => error?.statusCode === 401
)
} finally {
;(User as any).findById = originalFindById
}
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { BridgeToken } from '~~/server/models/BridgeToken'
import { getBridgeLog } from '~~/server/utils/bridgeLog'
function createEvent(headers: Record<string, string> = {}) {
return {
node: {
req: { headers },
},
context: {},
} as any
}
describe('/api/bridge/me handler', () => {
it('returns 401 when token header is missing', async () => {
;(globalThis as any).defineEventHandler = (handler: any) => handler
const mod = await import('~~/server/api/bridge/me.get')
const handler = mod.default
await assert.rejects(
() => handler(createEvent()),
(error: any) => error?.statusCode === 401
)
})
it('returns disconnected status when token has no linked user', async () => {
;(globalThis as any).defineEventHandler = (handler: any) => handler
const mod = await import('~~/server/api/bridge/me.get')
const handler = mod.default
const originalFindOne = (BridgeToken as any).findOne
;(BridgeToken as any).findOne = () => ({
populate: async () => ({
token: 'bridge-token-abc',
user: null,
connectedAt: new Date('2025-01-01T10:00:00.000Z'),
lastStatusAt: null,
}),
})
try {
const result = await handler(createEvent({ 'x-bridge-token': 'bridge-token-abc' }))
assert.equal(result.connected, false)
assert.equal(result.user, null)
assert.equal(result.simConnected, false)
assert.equal(result.flightActive, false)
assert.equal(result.connectedAt, '2025-01-01T10:00:00.000Z')
const logs = getBridgeLog('bridge-token-abc')
assert.equal(logs.length > 0, true)
assert.match(logs[logs.length - 1]!.summary, /not connected/i)
} finally {
;(BridgeToken as any).findOne = originalFindOne
}
})
it('returns connected status with mapped user fields', async () => {
;(globalThis as any).defineEventHandler = (handler: any) => handler
const mod = await import('~~/server/api/bridge/me.get')
const handler = mod.default
const originalFindOne = (BridgeToken as any).findOne
;(BridgeToken as any).findOne = () => ({
populate: async () => ({
token: 'bridge-token-live',
user: {
_id: '507f1f77bcf86cd799439055',
email: 'pilot@example.com',
name: 'Pilot',
},
simConnected: true,
flightActive: true,
connectedAt: undefined,
updatedAt: new Date('2025-01-01T11:00:00.000Z'),
lastStatusAt: new Date('2025-01-01T11:02:00.000Z'),
}),
})
try {
const result = await handler(createEvent({ 'x-bridge-token': 'bridge-token-live' }))
assert.equal(result.connected, true)
assert.equal(result.user.id, '507f1f77bcf86cd799439055')
assert.equal(result.user.email, 'pilot@example.com')
assert.equal(result.user.name, 'Pilot')
assert.equal(result.simConnected, true)
assert.equal(result.flightActive, true)
assert.equal(result.connectedAt, '2025-01-01T11:00:00.000Z')
assert.equal(result.lastStatusAt, '2025-01-01T11:02:00.000Z')
} finally {
;(BridgeToken as any).findOne = originalFindOne
}
})
})

View File

@@ -0,0 +1,85 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getBridgeLog, logBridgeEvent } from '~~/server/utils/bridgeLog'
import { getBridgeTokenFromHeader, normalizeBridgeToken } from '~~/server/utils/bridge'
import { flightlabTelemetryStore } from '~~/server/utils/flightlabTelemetry'
describe('bridge token handling', () => {
it('normalizes valid tokens and rejects invalid inputs', () => {
assert.equal(normalizeBridgeToken(' bridge-token-123 '), 'bridge-token-123')
assert.equal(normalizeBridgeToken('abc'), null)
assert.equal(normalizeBridgeToken(' '.repeat(10)), null)
assert.equal(normalizeBridgeToken(1234), null)
assert.equal(normalizeBridgeToken('x'.repeat(257)), null)
})
it('reads and normalizes x-bridge-token from request headers', () => {
const event = {
node: {
req: {
headers: {
'x-bridge-token': ' live-bridge-token ',
},
},
},
} as any
assert.equal(getBridgeTokenFromHeader(event), 'live-bridge-token')
})
})
describe('bridge log store', () => {
it('keeps only the latest 200 entries and supports since filtering', () => {
const token = `tok-${Date.now()}-${Math.random().toString(36).slice(2)}`
for (let i = 0; i < 205; i++) {
logBridgeEvent(token, {
endpoint: '/api/bridge/data',
method: 'POST',
statusCode: 200,
color: 'green',
summary: `entry-${i}`,
})
}
const all = getBridgeLog(token)
assert.equal(all.length, 200)
assert.equal(all[0]!.summary, 'entry-5')
assert.equal(all[199]!.summary, 'entry-204')
assert.equal(all[0]!.id < all[199]!.id, true)
const sinceId = all[149]!.id
const sinceEntries = getBridgeLog(token, sinceId)
assert.equal(sinceEntries.length, 50)
assert.equal(sinceEntries.every((entry) => entry.id > sinceId), true)
})
})
describe('flightlab telemetry store', () => {
it('stores latest telemetry and notifies listeners', () => {
const userId = `user-${Date.now()}`
const received: any[] = []
const unsubscribe = flightlabTelemetryStore.subscribe((incomingUserId, data) => {
if (incomingUserId === userId) {
received.push(data)
}
})
flightlabTelemetryStore.update(userId, { IAS: 121, ALT: 4500 })
const latest = flightlabTelemetryStore.get(userId)
assert.equal(latest?.IAS, 121)
assert.equal(latest?.ALT, 4500)
assert.equal(typeof latest?.timestamp, 'number')
assert.equal(received.length, 1)
const callsBeforeUnsubscribe = received.length
unsubscribe()
flightlabTelemetryStore.update(userId, { IAS: 130 })
assert.equal(flightlabTelemetryStore.get(userId)?.IAS, 130)
assert.equal(received.length, callsBeforeUnsubscribe)
})
})

View File

@@ -0,0 +1,65 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getServerRuntimeConfig, resetServerRuntimeConfigCache } from '~~/server/utils/runtimeConfig'
describe('runtimeConfig', () => {
it('parses env-driven runtime values and applies defaults', () => {
process.env.OPENAI_API_KEY = ' key-123 '
process.env.OPENAI_PROJECT = ' proj-1 '
process.env.OPENAI_BASE_URL = ' https://api.openai.local/v1 '
process.env.OPENAI_LLM_MODEL = ' gpt-5-mini '
process.env.OPENAI_TTS_MODEL = ' tts-2 '
process.env.OPENAI_VOICE_ID = ' nova '
process.env.OPENAIP_API_KEY = ' oaip-1 '
process.env.USE_PIPER = 'yes'
process.env.PIPER_PORT = '7002'
process.env.USE_SPEACHES = 'off'
process.env.SPEACHES_BASE_URL = ' http://localhost:7777 '
process.env.SPEECH_MODEL_ID = ' custom/model '
resetServerRuntimeConfigCache()
const config = getServerRuntimeConfig()
assert.equal(config.openaiKey, 'key-123')
assert.equal(config.openaiProject, 'proj-1')
assert.equal(config.openaiBaseUrl, 'https://api.openai.local/v1')
assert.equal(config.llmModel, 'gpt-5-mini')
assert.equal(config.ttsModel, 'tts-2')
assert.equal(config.voiceId, 'nova')
assert.equal(config.openaipApiKey, 'oaip-1')
assert.equal(config.usePiper, true)
assert.equal(config.piperPort, 7002)
assert.equal(config.useSpeaches, false)
assert.equal(config.speachesBaseUrl, 'http://localhost:7777')
assert.equal(config.speechModelId, 'custom/model')
})
it('caches values until reset is called', () => {
process.env.OPENAI_LLM_MODEL = 'gpt-cached-a'
resetServerRuntimeConfigCache()
const first = getServerRuntimeConfig()
process.env.OPENAI_LLM_MODEL = 'gpt-cached-b'
const second = getServerRuntimeConfig()
assert.equal(first.llmModel, 'gpt-cached-a')
assert.equal(second.llmModel, 'gpt-cached-a')
resetServerRuntimeConfigCache()
const third = getServerRuntimeConfig()
assert.equal(third.llmModel, 'gpt-cached-b')
})
it('falls back for invalid booleans and number values', () => {
process.env.USE_PIPER = 'not-a-bool'
process.env.USE_SPEACHES = ''
process.env.PIPER_PORT = 'invalid-port'
resetServerRuntimeConfigCache()
const config = getServerRuntimeConfig()
assert.equal(config.usePiper, false)
assert.equal(config.useSpeaches, false)
assert.equal(config.piperPort, 5001)
})
})

View File

@@ -3,14 +3,17 @@ export function useRuntimeConfig() {
openaiKey: process.env.OPENAI_API_KEY || '',
openaiProject: process.env.OPENAI_PROJECT || '',
openaiBaseUrl: process.env.OPENAI_BASE_URL || '',
jwtSecret: process.env.JWT_SECRET || '',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || '',
manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD || '',
llmModel: process.env.OPENAI_LLM_MODEL || 'gpt-5-nano',
ttsModel: process.env.OPENAI_TTS_MODEL || 'tts-1',
defaultVoiceId: process.env.OPENAI_VOICE_ID || 'alloy',
openaipApiKey: process.env.OPENAIP_API_KEY || '',
usePiper: false,
piperPort: 5001,
useSpeaches: false,
speachesBaseUrl: '',
speechModelId: 'speaches-ai/piper-en_US-ryan-low',
usePiper: process.env.USE_PIPER ?? false,
piperPort: process.env.PIPER_PORT ?? 5001,
useSpeaches: process.env.USE_SPEACHES ?? false,
speachesBaseUrl: process.env.SPEACHES_BASE_URL || '',
speechModelId: process.env.SPEECH_MODEL_ID || 'speaches-ai/piper-en_US-ryan-low',
}
}