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.
135 lines
7.2 KiB
TypeScript
135 lines
7.2 KiB
TypeScript
// 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')
|
|
})
|
|
})
|