// apps/hq/test/module-fields.test.ts — D21: module-defined field schema + values + secrets. import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { apiRouter } from '../src/api' import { createStaff } from '../src/auth' import { createClient } from '../src/repos-clients' import { createModule, assignModule, updateModule, getModule, setModuleFieldValues, setModuleSecret, revealModuleSecret, getClientModule, validateFieldSpec, } from '../src/repos-modules' const KEY = '33'.repeat(32) async function world() { const db = openDb(':memory:') await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' }) const rtgs = await createModule(db, 'u1', { code: 'RTGS', name: 'RTGS', fieldSpec: [ { key: 'ip', label: 'IP address', type: 'text' }, { key: 'ifsc', label: 'IFSC', type: 'text' }, { key: 'partner_bank', label: 'Partner bank', type: 'select', options: ['SBI', 'ICICI', 'HDFC'] }, { key: 'portal_password', label: 'Portal password', type: 'secret' }, { key: 'go_live', label: 'Go live', type: 'date' }, ], }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: rtgs.id, kind: 'yearly' }) return { db, c, rtgs, cm } } describe('module field_spec (D21)', () => { it('validates keys, labels, types and select options', () => { expect(() => validateFieldSpec([{ key: 'Bad Key', label: 'x', type: 'text' }])).toThrow(/snake_case/) expect(() => validateFieldSpec([{ key: 'a', label: '', type: 'text' }])).toThrow(/label/) expect(() => validateFieldSpec([{ key: 'a', label: 'A', type: 'bogus' }])).toThrow(/type/) expect(() => validateFieldSpec([{ key: 'a', label: 'A', type: 'select' }])).toThrow(/options/) expect(() => validateFieldSpec([ { key: 'a', label: 'A', type: 'text' }, { key: 'a', label: 'B', type: 'text' }, ])).toThrow(/duplicate/) const ok = validateFieldSpec([{ key: 'ip', label: 'IP', type: 'text', required: true }]) expect(ok[0]!.key).toBe('ip') }) it('carries field_spec on the module and is editable', async () => { const { db, rtgs } = await world() expect((await getModule(db, rtgs.id))!.fieldSpec).toHaveLength(5) const upd = await updateModule(db, 'u1', rtgs.id, { fieldSpec: [{ key: 'ip', label: 'IP address', type: 'text' }], }) expect(upd.fieldSpec).toHaveLength(1) }) }) describe('client_module field values + secrets (D21)', () => { it('stores non-secret values keyed by field, validating type/options; rejects unknown + secret keys', async () => { const { db, cm } = await world() const upd = await setModuleFieldValues(db, 'u1', cm.id, { ip: '192.168.10.8', ifsc: 'SBIN0001234', partner_bank: 'SBI', go_live: '2025-10-13', }) expect(upd.fieldValues).toEqual({ ip: '192.168.10.8', ifsc: 'SBIN0001234', partner_bank: 'SBI', go_live: '2025-10-13' }) await expect(setModuleFieldValues(db, 'u1', cm.id, { partner_bank: 'AXIS' })).rejects.toThrow(/one of/) await expect(setModuleFieldValues(db, 'u1', cm.id, { go_live: 'nope' })).rejects.toThrow(/YYYY-MM-DD/) await expect(setModuleFieldValues(db, 'u1', cm.id, { nope: 'x' })).rejects.toThrow(/Unknown field/) await expect(setModuleFieldValues(db, 'u1', cm.id, { portal_password: 'x' })).rejects.toThrow(/secret/) // '' clears a value. const cleared = await setModuleFieldValues(db, 'u1', cm.id, { ifsc: '' }) expect(cleared.fieldValues['ifsc']).toBeUndefined() expect(cleared.fieldValues['ip']).toBe('192.168.10.8') // others untouched }) it('encrypts secret fields, tracks which are set without decrypting, reveals audited, never leaks plaintext', async () => { const { db, cm } = await world() await expect(setModuleSecret(db, 'u1', cm.id, 'portal_password', 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/) await expect(setModuleSecret(db, 'u1', cm.id, 'ip', 'x', KEY)).rejects.toThrow(/not a secret/) const withSecret = await setModuleSecret(db, 'u1', cm.id, 'portal_password', 's3cr3t', KEY) expect(withSecret.secretKeys).toEqual(['portal_password']) expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') const stored = (await db.get<{ secrets_enc: string }>(`SELECT secrets_enc FROM client_module WHERE id=?`, cm.id))! expect(stored.secrets_enc).not.toContain('s3cr3t') // encrypted at rest expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).toBe('s3cr3t') // Audit carried only the key + set flag, never the value. const blob = (await db.all<{ after_json: string | null }>( `SELECT after_json FROM audit_log WHERE action='set_module_secret'`, )).map((r) => r.after_json ?? '').join('') expect(blob).toContain('portal_password') expect(blob).not.toContain('s3cr3t') // Clearing removes it. const cleared = await setModuleSecret(db, 'u1', cm.id, 'portal_password', '', KEY) expect(cleared.secretKeys).toEqual([]) await expect(revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).rejects.toThrow(/No value/) }) }) describe('D21 routes (gating)', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('staff set field values but NOT secrets; owner sets + reveals', async () => { const { db, cm } = await world() await createStaff(db, { email: 'staff@t.in', displayName: 'S', role: 'staff', password: 'staff-pass-1' }) await createStaff(db, { email: 'own@t.in', displayName: 'O', role: 'owner', password: 'owner-pass-1' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { keyHex: KEY })) 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 const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) const staff = await tok('staff@t.in', 'staff-pass-1') const owner = await tok('own@t.in', 'owner-pass-1') const fields = await fetch(`${base}/client-modules/${cm.id}/fields`, { method: 'PUT', headers: H(staff), body: JSON.stringify({ values: { ip: '10.0.0.1' } }), }) expect(fields.status).toBe(200) const denySecret = await fetch(`${base}/client-modules/${cm.id}/secrets`, { method: 'POST', headers: H(staff), body: JSON.stringify({ key: 'portal_password', value: 'x' }), }) expect(denySecret.status).toBe(403) const setSecret = await fetch(`${base}/client-modules/${cm.id}/secrets`, { method: 'POST', headers: H(owner), body: JSON.stringify({ key: 'portal_password', value: 'topsecret' }), }) expect(setSecret.status).toBe(200) const reveal = await (await fetch(`${base}/client-modules/${cm.id}/reveal-secret`, { method: 'POST', headers: H(owner), body: JSON.stringify({ key: 'portal_password' }), })).json() as { value: string } expect(reveal.value).toBe('topsecret') }) })