// 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('search + filters are case-insensitive (D24 — works on Postgres too)', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) await createClient(db, 'u1', { name: 'Karapuzha Service Bank', stateCode: '32', district: 'Ernakulam' }) // Lower-cased query matches the mixed-case name; wrong-case district still matches. expect(await listClients(db, 'karapuzha')).toHaveLength(1) expect(await listClients(db, 'BANK')).toHaveLength(1) expect(await listClients(db, undefined, { district: 'ERNAKULAM' })).toHaveLength(1) }) it('DB password (D31): stored in the clear, never in list payloads, reveal 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', '') // D31: no key needed const after = (await getClient(db, c.id))! expect(after.hasDbPassword).toBe(true) expect(JSON.stringify(after)).not.toContain('oracle#secret') // still never in the list payload expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('oracle#secret') // reveal, no key 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('D31: stores and reveals with no key (KEY is ignored, not required)', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) await setClientDbPassword(db, 'u1', c.id, 'pw', '') // no key → succeeds expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('pw') await setClientDbPassword(db, 'u1', c.id, 'pw2', KEY) // a key is accepted but ignored expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('pw2') }) }) 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' }) }) })