|
|
// apps/hq/test/authz-gates.test.ts — D24 (red-team): destructive/sensitive ops gated managerial.
|
|
|
import express from 'express'
|
|
|
import { describe, it, expect, afterAll } from 'vitest'
|
|
|
import { openDb } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { createStaff } from '../src/auth'
|
|
|
import { createClient } from '../src/repos-clients'
|
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
|
import { createDraft, issueDocument } from '../src/repos-documents'
|
|
|
import { createInteraction } from '../src/repos-interactions'
|
|
|
import { apiRouter } from '../src/api'
|
|
|
|
|
|
const servers: { close: () => void }[] = []
|
|
|
afterAll(() => { for (const s of servers) s.close() })
|
|
|
|
|
|
async function ctx() {
|
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
|
await createStaff(db, { email: 'owner@t.in', displayName: 'O', role: 'owner', password: 'owner-pass-1' })
|
|
|
await createStaff(db, { email: 'staff@t.in', displayName: 'S', role: 'staff', password: 'staff-pass-1' })
|
|
|
const staffB = await createStaff(db, { email: 'staff2@t.in', displayName: 'S2', role: 'staff', password: 'staff-pass-2' })
|
|
|
const c = await createClient(db, 'u1', { name: 'Bank', stateCode: '32' })
|
|
|
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core' })
|
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
|
|
|
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
|
})).id)
|
|
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
|
|
app.set('trust proxy', 1)
|
|
|
app.use('/api', apiRouter(db, { keyHex: '11'.repeat(32) }))
|
|
|
const server = app.listen(0); servers.push(server)
|
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
|
const tok = async (e: string, p: string) => ((await (await fetch(`${base}/auth/login`, {
|
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: e, password: p }),
|
|
|
})).json()) as { token: string }).token
|
|
|
return { db, c, m, inv, staffB, base, tok }
|
|
|
}
|
|
|
const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` })
|
|
|
|
|
|
describe('D24 authorization gates', () => {
|
|
|
it('cancel / credit-note / public share are managerial-only; owner can', async () => {
|
|
|
const x = await ctx()
|
|
|
const staff = await x.tok('staff@t.in', 'staff-pass-1')
|
|
|
const owner = await x.tok('owner@t.in', 'owner-pass-1')
|
|
|
for (const path of [`/documents/${x.inv.id}/cancel`, `/documents/${x.inv.id}/credit-note`, `/documents/${x.inv.id}/share`]) {
|
|
|
const staffRes = await fetch(`${x.base}${path}`, { method: 'POST', headers: H(staff), body: '{}' })
|
|
|
expect(staffRes.status, `${path} staff`).toBe(403)
|
|
|
}
|
|
|
// Owner can mint a share (the one that has no other preconditions).
|
|
|
const share = await fetch(`${x.base}/documents/${x.inv.id}/share`, { method: 'POST', headers: H(owner), body: '{}' })
|
|
|
expect(share.status).toBe(200)
|
|
|
})
|
|
|
|
|
|
it('a staff user cannot edit another employee’s interaction; the author can', async () => {
|
|
|
const x = await ctx()
|
|
|
// Author = staffB; log it directly with staffB's id.
|
|
|
const it2 = await createInteraction(x.db, x.staffB.id, { clientId: x.c.id, typeCode: 'call', onDate: '2026-07-10' })
|
|
|
const other = await x.tok('staff@t.in', 'staff-pass-1') // different staff
|
|
|
const author = await x.tok('staff2@t.in', 'staff-pass-2') // the author
|
|
|
const deny = await fetch(`${x.base}/interactions/${it2.id}`, { method: 'PATCH', headers: H(other), body: JSON.stringify({ notes: 'tampered' }) })
|
|
|
expect(deny.status).toBe(403)
|
|
|
const ok = await fetch(`${x.base}/interactions/${it2.id}`, { method: 'PATCH', headers: H(author), body: JSON.stringify({ notes: 'my note' }) })
|
|
|
expect(ok.status).toBe(200)
|
|
|
})
|
|
|
})
|