// apps/hq/test/client-support.test.ts — go-live cluster WS-F: client support data (D18) 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, getClient, listClients, revealClientDbPassword, setClientDbPassword, updateClient, } from '../src/repos-clients' import { listAudit } from '../src/audit' import { apiRouter } from '../src/api' const KEY = '22'.repeat(32) describe('client support data (repo)', () => { it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme Bank', stateCode: '32', anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop', }) expect(c).toMatchObject({ anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop' }) const patched = await updateClient(db, 'u1', c.id, { anydesk: '', district: 'Satara' }) expect(patched.anydesk).toBeUndefined() // '' clears expect(patched.district).toBe('Satara') }) it('district/sector filter the client book', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) await createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' }) await createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' }) await createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' }) expect(await listClients(db, undefined, { district: 'Pune' })).toHaveLength(2) expect(await listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1) expect(await listClients(db)).toHaveLength(3) // no filter = everything, unchanged }) it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) expect(c.hasDbPassword).toBe(false) await setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY) const after = (await getClient(db, c.id))! expect(after.hasDbPassword).toBe(true) expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload const raw = (await db.get<{ db_password_enc: string }>(`SELECT db_password_enc FROM client WHERE id=?`, c.id))! expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret') const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id) expect(reveals).toHaveLength(1) // The set audit records THAT it was set, never the value. const sets = (await listAudit(db)).filter((a) => a.action === 'set_db_password') expect(sets).toHaveLength(1) expect(sets[0]!.after_json).not.toContain('oracle') }) it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) await expect(setClientDbPassword(db, 'u1', c.id, 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/) await setClientDbPassword(db, 'u1', c.id, 'pw', KEY) await expect(revealClientDbPassword(db, 'u1', c.id, '')).rejects.toThrow(/HQ_SECRET_KEY/) }) }) describe('client support data (routes)', () => { async function appWith() { const db = openDb(':memory:'); await seedIfEmpty(db) await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { keyHex: KEY })) const server = app.listen(0) const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, baseUrl, c } } const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) const login = async (baseUrl: string, email: string, password: string) => ((await (await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json()) as { token: string }).token it('staff can patch support text fields but NOT the db password; owner can; reveal is gated too', async () => { const ctx = await appWith(); servers.push(ctx.server) const staffTok = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const ownerTok = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) // staff: text fields fine const r1 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, { method: 'PATCH', headers: H(staffTok), body: JSON.stringify({ anydesk: '999 888 777' }), }) expect(r1.status).toBe(200) // staff: dbPassword refused, and nothing else in that request is applied const r2 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, { method: 'PATCH', headers: H(staffTok), body: JSON.stringify({ dbPassword: 'sneaky' }), }) expect(r2.status).toBe(403) // owner: sets it; payload shows only the flag const r3 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, { method: 'PATCH', headers: H(ownerTok), body: JSON.stringify({ dbPassword: 'oracle#secret' }), }) const j3 = await r3.json() as { client: Record } expect(r3.status).toBe(200) expect(j3.client['hasDbPassword']).toBe(true) expect(JSON.stringify(j3)).not.toContain('oracle#secret') // reveal: staff 403, owner 200 + audited expect((await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}/reveal-db-password`, { method: 'POST', headers: H(staffTok), body: '{}' })).status).toBe(403) const r4 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}/reveal-db-password`, { method: 'POST', headers: H(ownerTok), body: '{}' }) expect(await r4.json()).toMatchObject({ ok: true, password: 'oracle#secret' }) }) })