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.
sims-hq/apps/store-server/test/edge.test.ts

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)
})
})