From db7b147e9c4f3d8e02516f73ecfba998abef5e78 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 17 Feb 2026 18:36:13 +0100 Subject: [PATCH] test: expand coverage for core backend features --- tests/server/auth.test.ts | 136 ++++++++++++++++++++++++++ tests/server/bridgeMe.handler.test.ts | 95 ++++++++++++++++++ tests/server/bridgeTelemetry.test.ts | 85 ++++++++++++++++ tests/server/runtimeConfig.test.ts | 65 ++++++++++++ tests/stubs/nuxt-imports.ts | 13 ++- 5 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 tests/server/auth.test.ts create mode 100644 tests/server/bridgeMe.handler.test.ts create mode 100644 tests/server/bridgeTelemetry.test.ts create mode 100644 tests/server/runtimeConfig.test.ts diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts new file mode 100644 index 0000000..63f168e --- /dev/null +++ b/tests/server/auth.test.ts @@ -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 +} + +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 + } + }) +}) diff --git a/tests/server/bridgeMe.handler.test.ts b/tests/server/bridgeMe.handler.test.ts new file mode 100644 index 0000000..0139474 --- /dev/null +++ b/tests/server/bridgeMe.handler.test.ts @@ -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 = {}) { + 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 + } + }) +}) diff --git a/tests/server/bridgeTelemetry.test.ts b/tests/server/bridgeTelemetry.test.ts new file mode 100644 index 0000000..a353dc4 --- /dev/null +++ b/tests/server/bridgeTelemetry.test.ts @@ -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) + }) +}) diff --git a/tests/server/runtimeConfig.test.ts b/tests/server/runtimeConfig.test.ts new file mode 100644 index 0000000..c80fe00 --- /dev/null +++ b/tests/server/runtimeConfig.test.ts @@ -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) + }) +}) diff --git a/tests/stubs/nuxt-imports.ts b/tests/stubs/nuxt-imports.ts index 3560e93..4a8d4e1 100644 --- a/tests/stubs/nuxt-imports.ts +++ b/tests/stubs/nuxt-imports.ts @@ -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', } }