mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
test: expand coverage for core backend features
This commit is contained in:
136
tests/server/auth.test.ts
Normal file
136
tests/server/auth.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
95
tests/server/bridgeMe.handler.test.ts
Normal file
95
tests/server/bridgeMe.handler.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
85
tests/server/bridgeTelemetry.test.ts
Normal file
85
tests/server/bridgeTelemetry.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
65
tests/server/runtimeConfig.test.ts
Normal file
65
tests/server/runtimeConfig.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user