You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
175 lines
8.6 KiB
TypeScript
175 lines
8.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import express from 'express'
|
|
import type { AddressInfo } from 'node:net'
|
|
import type { Server } from 'node:http'
|
|
import { openDb, type DB } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { apiRouter, type ApiRouterOpts } from '../src/api'
|
|
import { MAX_PRINT_BYTES } from '../src/print-guard'
|
|
|
|
/**
|
|
* P1 edge / transport hardening (red-team H1, M1, M2, M4) over the REAL HTTP surface: a mounted
|
|
* apiRouter, driven with fetch — the exact surface each finding lives on. Pure guards are in
|
|
* edge-guards.test.ts. Seed users: u1 Ramesh/cashier (4728), u2 Suresh/supervisor (8265),
|
|
* u3 Thomas/owner (9174).
|
|
*/
|
|
interface Harness { db: DB; url: string; server: Server }
|
|
|
|
async function start(opts?: ApiRouterOpts): Promise<Harness> {
|
|
const db = openDb(':memory:')
|
|
seedIfEmpty(db)
|
|
const app = express()
|
|
app.use(express.json({ limit: '1mb' }))
|
|
app.use('/api', apiRouter(db, opts))
|
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
|
|
const status = typeof e.status === 'number' ? e.status : 500
|
|
if (res.headersSent) return
|
|
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
|
|
})
|
|
const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
|
|
const port = (server.address() as AddressInfo).port
|
|
return { db, url: `http://127.0.0.1:${port}`, server }
|
|
}
|
|
|
|
async function login(url: string, userId: string, pin: string): Promise<string> {
|
|
const res = await fetch(`${url}/api/auth/pin`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
|
|
})
|
|
const body = (await res.json()) as { token?: string }
|
|
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
|
return body.token
|
|
}
|
|
|
|
const post = (url: string, path: string, token: string | undefined, body: unknown) =>
|
|
fetch(`${url}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
const get = (url: string, path: string, token?: string) =>
|
|
fetch(`${url}${path}`, { headers: token !== undefined ? { authorization: `Bearer ${token}` } : {} })
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
|
|
let h: Harness
|
|
afterEach(() => { h.server.close() })
|
|
|
|
describe('M1 — /bootstrap minimize', () => {
|
|
beforeEach(async () => { h = await start() })
|
|
it('unauthenticated payload leaks NO role and NO gstin', async () => {
|
|
const boot = (await (await get(h.url, '/api/bootstrap')).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
|
expect(boot.store['gstin']).toBeUndefined()
|
|
expect(boot.users.length).toBeGreaterThan(0)
|
|
for (const u of boot.users) {
|
|
expect(u['role']).toBeUndefined()
|
|
expect(u['name']).toBeDefined() // display name kept
|
|
expect(u['id']).toBeDefined() // id kept (PIN login target)
|
|
}
|
|
})
|
|
it('an authenticated caller gets role + gstin back', async () => {
|
|
const token = await login(h.url, 'u1', '4728')
|
|
const boot = (await (await get(h.url, '/api/bootstrap', token)).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
|
expect(boot.store['gstin']).toBeDefined()
|
|
expect(boot.users.every((u) => u['role'] !== undefined)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('M2 — /auth/verify-pin behind auth', () => {
|
|
beforeEach(async () => { h = await start() })
|
|
const override = { itemId: 'i-x', kind: 'price', before: 10000, after: 9000 }
|
|
it('is 401 UNAUTHENTICATED (no caller session)', async () => {
|
|
const res = await post(h.url, '/api/auth/verify-pin', undefined, { userId: 'u2', pin: '8265', override })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
it('still mints a bound token when the caller is a logged-in cashier', async () => {
|
|
const cashier = await login(h.url, 'u1', '4728')
|
|
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '8265', override })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { approvalId?: string }
|
|
expect(body.approvalId).toBeDefined()
|
|
})
|
|
it('a wrong approver PIN is still rejected (401) even with a valid caller', async () => {
|
|
const cashier = await login(h.url, 'u1', '4728')
|
|
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '0000', override })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe('M4 — session TTL + logout', () => {
|
|
it('evicts a session past its idle TTL (401 E-6110)', async () => {
|
|
h = await start({ sessionTtl: { absoluteMs: 3_600_000, idleMs: 20 } })
|
|
const token = await login(h.url, 'u1', '4728')
|
|
expect((await get(h.url, '/api/items', token)).status).toBe(200) // fresh: allowed
|
|
await sleep(80)
|
|
const res = await get(h.url, '/api/items', token)
|
|
expect(res.status).toBe(401)
|
|
expect(((await res.json()) as { code: string }).code).toBe('E-6110')
|
|
})
|
|
it('logout makes the token stop working immediately', async () => {
|
|
h = await start()
|
|
const token = await login(h.url, 'u1', '4728')
|
|
expect((await get(h.url, '/api/items', token)).status).toBe(200)
|
|
expect((await post(h.url, '/api/auth/logout', token, {})).status).toBe(200)
|
|
expect((await get(h.url, '/api/items', token)).status).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe('M4/M2 — auth rate limit', () => {
|
|
it('429s once the per-IP cap is exceeded, before the handler runs', async () => {
|
|
h = await start({ authRatePerIp: { windowMs: 60_000, max: 2 } })
|
|
const r1 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
const r2 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
const r3 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
expect(r1.status).toBe(401) // wrong pin, but processed
|
|
expect(r2.status).toBe(401)
|
|
expect(r3.status).toBe(429) // rate-limited before reaching the handler
|
|
})
|
|
})
|
|
|
|
describe('H1 — /api/print allowlist over HTTP', () => {
|
|
beforeEach(async () => { h = await start() })
|
|
it('rejects an unauthenticated caller (401)', async () => {
|
|
const res = await post(h.url, '/api/print', undefined, { host: '127.0.0.1', port: 9100, bytes: [1] })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
it('rejects a PUBLIC IP even on an allowed port (E-2203, no socket opened)', async () => {
|
|
const token = await login(h.url, 'u1', '4728')
|
|
const res = await post(h.url, '/api/print', token, { host: '8.8.8.8', port: 9100, bytes: [1, 2, 3] })
|
|
expect(res.status).toBe(400)
|
|
expect(((await res.json()) as { code: string }).code).toBe('E-2203')
|
|
})
|
|
it('rejects a disallowed port (E-2202)', async () => {
|
|
const token = await login(h.url, 'u1', '4728')
|
|
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 80, bytes: [1] })
|
|
expect(((await res.json()) as { code: string }).code).toBe('E-2202')
|
|
})
|
|
it('rejects an over-cap payload (E-2201)', async () => {
|
|
const token = await login(h.url, 'u1', '4728')
|
|
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: new Array(MAX_PRINT_BYTES + 1).fill(0) })
|
|
expect(((await res.json()) as { code: string }).code).toBe('E-2201')
|
|
})
|
|
it('ALLOWS a LAN target on an allowed port through the guard (reaches the socket → connection refused)', async () => {
|
|
const token = await login(h.url, 'u1', '4728')
|
|
// Nothing is listening on 127.0.0.1:9100 in the test, so the guard passing is proven by the
|
|
// response being a socket outcome (502 refused / 504 timeout), NOT a 400 guard rejection.
|
|
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: [27, 64] })
|
|
expect([502, 504, 200]).toContain(res.status)
|
|
})
|
|
})
|
|
|
|
describe('H2 — GET /audit/verify (AUDIT_VIEW gated)', () => {
|
|
beforeEach(async () => { h = await start() })
|
|
it('an owner gets ok:true on an untampered chain (with a real audit row); a cashier is 403', async () => {
|
|
// a wrong-PIN attempt writes a real LOGIN_FAILED row, so the chain isn't empty
|
|
await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
const owner = await login(h.url, 'u3', '9174')
|
|
const res = await get(h.url, '/api/audit/verify', owner)
|
|
expect(res.status).toBe(200)
|
|
expect(((await res.json()) as { ok: boolean }).ok).toBe(true)
|
|
const cashier = await login(h.url, 'u1', '4728')
|
|
expect((await get(h.url, '/api/audit/verify', cashier)).status).toBe(403)
|
|
})
|
|
})
|