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 { 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 { 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; users: Record[] } 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; users: Record[] } 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) }) })