diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts index 63f168e..a7ddb58 100644 --- a/tests/server/auth.test.ts +++ b/tests/server/auth.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' import { createAccessToken, @@ -8,10 +9,46 @@ import { hashPassword, requireUserSession, resolveUserFromToken, + rotateRefreshToken, verifyPassword, } from '~~/server/utils/auth' import { User } from '~~/server/models/User' +const REFRESH_COOKIE_NAME = 'os_refresh_token' + +// Minimal H3-ish event with a writable response so setCookie/deleteCookie work. +function makeEvent(opts: { cookie?: string } = {}) { + const headers: Record = {} + return { + node: { + req: { headers: opts.cookie ? { cookie: opts.cookie } : {} }, + res: { + setHeader: (k: string, v: any) => { headers[k.toLowerCase()] = v }, + getHeader: (k: string) => headers[k.toLowerCase()], + removeHeader: (k: string) => { delete headers[k.toLowerCase()] }, + getHeaderNames: () => Object.keys(headers), + headersSent: false, + }, + }, + context: {}, + } as any +} + +function b64url(buf: Buffer) { + return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +// Hand-rolls a JWT so tests can forge headers/claims the public helpers won't produce. +function signJwt(header: Record, payload: Record, secret: string) { + const data = `${b64url(Buffer.from(JSON.stringify(header)))}.${b64url(Buffer.from(JSON.stringify(payload)))}` + const sig = createHmac('sha256', secret).update(data).digest() + return `${data}.${b64url(sig)}` +} + +function bearerEvent(token: string) { + return { node: { req: { headers: { authorization: `Bearer ${token}` } } }, context: {} } as any +} + function decodeJwtPayload(token: string) { const parts = token.split('.') assert.equal(parts.length, 3) @@ -134,3 +171,98 @@ describe('auth utils', () => { } }) }) + +describe('refresh token rotation', () => { + it('rotates a valid refresh token into a fresh access token', async () => { + process.env.JWT_SECRET = 'rotate-access-secret' + process.env.JWT_REFRESH_SECRET = 'rotate-refresh-secret' + + const user = { _id: '507f1f77bcf86cd799439011', email: 'p@example.com', tokenVersion: 3, role: 'user' } as any + const refresh = createRefreshToken(user) + const event = makeEvent({ cookie: `${REFRESH_COOKIE_NAME}=${refresh}` }) + + const originalFindById = (User as any).findById + ;(User as any).findById = async (id: string) => (id === user._id ? user : null) + try { + const { accessToken } = await rotateRefreshToken(event) + assert.equal(typeof accessToken, 'string') + // a new refresh cookie was issued + assert.match(String(event.node.res.getHeader('set-cookie')), new RegExp(REFRESH_COOKIE_NAME)) + } finally { + ;(User as any).findById = originalFindById + } + }) + + it('rejects when no refresh cookie is present', async () => { + process.env.JWT_SECRET = 'rotate-access-secret' + const event = makeEvent() + await assert.rejects(() => rotateRefreshToken(event), (e: any) => e?.statusCode === 401) + }) + + it('rejects an access token presented as a refresh token (wrong type)', async () => { + process.env.JWT_SECRET = 'rotate-access-secret' + process.env.JWT_REFRESH_SECRET = 'rotate-refresh-secret' + + const user = { _id: '507f1f77bcf86cd799439011', email: 'p@example.com', tokenVersion: 1, role: 'user' } as any + // access tokens have no type:'refresh' claim and are signed with the access secret + const access = createAccessToken(user) + const event = makeEvent({ cookie: `${REFRESH_COOKIE_NAME}=${access}` }) + + await assert.rejects(() => rotateRefreshToken(event), (e: any) => e?.statusCode === 401) + }) + + it('rejects a refresh token whose version no longer matches the user', async () => { + process.env.JWT_SECRET = 'rotate-access-secret' + process.env.JWT_REFRESH_SECRET = 'rotate-refresh-secret' + + const tokenUser = { _id: '507f1f77bcf86cd799439011', email: 'p@example.com', tokenVersion: 1, role: 'user' } as any + const refresh = createRefreshToken(tokenUser) + const event = makeEvent({ cookie: `${REFRESH_COOKIE_NAME}=${refresh}` }) + + const originalFindById = (User as any).findById + ;(User as any).findById = async () => ({ ...tokenUser, tokenVersion: 2 }) + try { + await assert.rejects(() => rotateRefreshToken(event), (e: any) => e?.statusCode === 401) + } finally { + ;(User as any).findById = originalFindById + } + }) +}) + +describe('JWT verification hardening', () => { + it('rejects a non-HS256 algorithm even with a valid HMAC signature (alg confusion)', async () => { + process.env.JWT_SECRET = 'harden-secret' + // signature is a valid HMAC over the data, but the header claims alg:none + const token = signJwt( + { alg: 'none', typ: 'JWT' }, + { sub: '507f1f77bcf86cd799439011', version: 0, iat: 0, exp: 9999999999 }, + 'harden-secret', + ) + assert.equal(await resolveUserFromToken(bearerEvent(token)), null) + }) + + it('rejects a tampered payload (signature mismatch)', async () => { + process.env.JWT_SECRET = 'harden-secret' + const user = { _id: '507f1f77bcf86cd799439011', email: 'p@example.com', tokenVersion: 0, role: 'user' } as any + const valid = createAccessToken(user) + const parts = valid.split('.') + // flip a character in the payload segment + parts[1] = parts[1]!.slice(0, -1) + (parts[1]!.endsWith('A') ? 'B' : 'A') + assert.equal(await resolveUserFromToken(bearerEvent(parts.join('.'))), null) + }) + + it('rejects an expired token', async () => { + process.env.JWT_SECRET = 'harden-secret' + const token = signJwt( + { alg: 'HS256', typ: 'JWT' }, + { sub: '507f1f77bcf86cd799439011', version: 0, iat: 0, exp: 1 }, + 'harden-secret', + ) + assert.equal(await resolveUserFromToken(bearerEvent(token)), null) + }) + + it('rejects a malformed token', async () => { + process.env.JWT_SECRET = 'harden-secret' + assert.equal(await resolveUserFromToken(bearerEvent('not-a-jwt')), null) + }) +}) diff --git a/tests/shared/communicationsEngine.test.ts b/tests/shared/communicationsEngine.test.ts new file mode 100644 index 0000000..7208084 --- /dev/null +++ b/tests/shared/communicationsEngine.test.ts @@ -0,0 +1,155 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import useCommunicationsEngine, { normalizeATCText } from '~~/shared/utils/communicationsEngine' +import type { RuntimeDecisionSystem, RuntimeDecisionTree } from '~~/shared/types/decision' + +// Minimal single-flow system. The start state REQ is a pilot state with NO +// transitions, so the engine's auto-advance leaves the cursor on it after load +// (otherwise evaluateSimpleAutoFlow would schedule a move). ACK/DONE are only +// reached via explicit moveToSilent() calls — mirroring the real setup where +// the Python backend drives state and the engine only syncs the local cursor. +function buildSystem(): RuntimeDecisionSystem { + const clearance: RuntimeDecisionTree = { + slug: 'clearance', + schema_version: '2.0', + name: 'Clearance', + description: 'test flow', + start_state: 'REQ', + end_states: ['DONE'], + variables: { + // VariableDefinition form (as the Python backend serializes it) — must be + // unwrapped to its `initial` value by the engine. + callsign: { name: 'callsign', type: 'string', initial: 'DLH123', mutable_by: 'action_only' }, + // raw form — must pass through untouched. + runway: '25R', + } as Record, + flags: { + gates_clear: { name: 'gates_clear', initial: true }, + } as Record, + policies: {}, + hooks: {}, + roles: ['pilot', 'atc', 'system'], + phases: ['clearance'], + states: { + REQ: { + role: 'pilot', + phase: 'clearance', + name: 'Request clearance', + expected_pilot_template: '{{callsign}} ready for clearance', + }, + ACK: { + role: 'atc', + phase: 'clearance', + name: 'Controller clearance', + say_template: '{{callsign}}, cleared to {{runway}}', + actions: [{ set: 'variables.cleared', to: true }], + }, + DONE: { role: 'system', phase: 'clearance', name: 'Complete' }, + }, + } + + return { main: 'clearance', order: ['clearance'], flows: { clearance } } +} + +describe('communicationsEngine — load & init', () => { + it('loads a system, becomes ready, and starts on the flow start state', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + assert.equal(engine.isReady.value, true) + assert.equal(engine.activeFlow.value, 'clearance') + assert.equal(engine.currentState.value?.id, 'REQ') + assert.equal(engine.currentState.value?.role, 'pilot') + }) + + it('unwraps VariableDefinition objects to their initial value and keeps raw values', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + // { initial: 'DLH123' } -> 'DLH123'; raw '25R' stays '25R' + assert.equal(engine.variables.value.callsign, 'DLH123') + assert.equal(engine.variables.value.runway, '25R') + // flag definition { initial: true } -> true + assert.equal((engine.flags.value as Record).gates_clear, true) + }) +}) + +describe('communicationsEngine — template rendering', () => { + it('renders both {{double}} and {single} brace variables (dual schema)', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + assert.equal( + engine.renderATCMessage('{{callsign}} cleared to {runway}'), + 'DLH123 cleared to 25R', + ) + }) + + it('reflects patched variables in subsequent renders', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + engine.patchVariables({ runway: '07L' }) + assert.equal(engine.renderATCMessage('{{runway}}'), '07L') + }) + + it('leaves unknown variables empty rather than printing the placeholder', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + assert.equal(engine.renderATCMessage('runway {{nonexistent}} done'), 'runway done') + }) +}) + +describe('communicationsEngine — moveToSilent', () => { + it('advances the cursor, runs state actions, and logs the controller phrase', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + engine.moveToSilent('ACK') + + assert.equal(engine.currentState.value?.id, 'ACK') + // ACK's action set variables.cleared = true + assert.equal(engine.variables.value.cleared, true) + + // speak() rendered + logged the say_template for the ACK state + const log = engine.communicationLog.value + const last = log[log.length - 1] + assert.equal(last?.state, 'ACK') + assert.match(last!.message, /DLH123/) + assert.match(last!.message, /25R/) + }) + + it('ignores a move to an unknown state', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + engine.moveToSilent('NOPE') + assert.equal(engine.currentState.value?.id, 'REQ') + }) + + it('getStateDetails returns the requested state or null', () => { + const engine = useCommunicationsEngine() + engine.loadRuntimeSystem(buildSystem()) + + assert.equal(engine.getStateDetails('ACK')?.role, 'atc') + assert.equal(engine.getStateDetails('MISSING'), null) + }) +}) + +describe('normalizeATCText', () => { + it('renders templates then expands to radiotelephony for TTS', () => { + const out = normalizeATCText('{{callsign}} cleared to {runway}', { + callsign: 'DLH123', + runway: '25R', + }) + + // template was rendered (no braces left) + assert.doesNotMatch(out, /[{}]/) + // callsign DLH expands to its telephony name + assert.match(out, /Lufthansa/) + // the callsign's digits become spoken words (123 -> wun too tree) + assert.match(out, /wun too tree/) + }) +})