mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
test: cover the core engine and auth rotation/JWT hardening (TEST-03, TEST-04)
TEST-03 — communicationsEngine had zero tests. Add tests/shared/
communicationsEngine.test.ts exercising the deterministic core: system load &
ready state, VariableDefinition unwrapping, dual {{}}/{} template rendering,
patchVariables, moveToSilent (cursor advance + state actions + controller log),
unknown-state handling, getStateDetails, and normalizeATCText expansion.
TEST-04 — auth utils were tested but rotation and JWT verification were not.
Extend tests/server/auth.test.ts with refresh-token rotation (valid rotate,
missing cookie, access-token-as-refresh, version mismatch) and JWT hardening
(alg-confusion rejection, tampered signature, expired, malformed).
97 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, any> = {}
|
||||
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<string, any>, payload: Record<string, any>, 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)
|
||||
})
|
||||
})
|
||||
|
||||
155
tests/shared/communicationsEngine.test.ts
Normal file
155
tests/shared/communicationsEngine.test.ts
Normal file
@@ -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<string, any>,
|
||||
flags: {
|
||||
gates_clear: { name: 'gates_clear', initial: true },
|
||||
} as Record<string, any>,
|
||||
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<string, any>).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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user