feat(clients): support-access data — AnyDesk/OS, district+sector filters, encrypted DB password with audited reveal (Phase 6, D18 WS-F)
- additive client columns; toClient never maps db_password_enc (payloads carry
only hasDbPassword); listClients gains district/sector filters
- setClientDbPassword: AES-256-GCM via HQ_SECRET_KEY, refuses loudly without a
key; revealClientDbPassword decrypts + writes one audit row per reveal
- PATCH /clients/🆔 dbPassword is owner/manager-gated inside the same route;
POST /clients/:id/reveal-db-password (owner/manager)
- client dialog gains district/sector/AnyDesk/OS; Client 360 gains the Support
card (copy AnyDesk, •••• + Reveal/Set for managerial)
- 5 tests; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
parent
28aa08c9c9
commit
30c2e3b60b
@ -0,0 +1,114 @@
|
||||
// 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', () => {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
const c = 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 = 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', () => {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' })
|
||||
createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' })
|
||||
createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' })
|
||||
expect(listClients(db, undefined, { district: 'Pune' })).toHaveLength(2)
|
||||
expect(listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1)
|
||||
expect(listClients(db)).toHaveLength(3) // no filter = everything, unchanged
|
||||
})
|
||||
|
||||
it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', () => {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
|
||||
expect(c.hasDbPassword).toBe(false)
|
||||
setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY)
|
||||
const after = getClient(db, c.id)!
|
||||
expect(after.hasDbPassword).toBe(true)
|
||||
expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload
|
||||
const raw = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`).get(c.id) as { db_password_enc: string }
|
||||
expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest
|
||||
expect(revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret')
|
||||
const reveals = 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 = 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)', () => {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
|
||||
expect(() => setClientDbPassword(db, 'u1', c.id, 'pw', '')).toThrow(/HQ_SECRET_KEY/)
|
||||
setClientDbPassword(db, 'u1', c.id, 'pw', KEY)
|
||||
expect(() => revealClientDbPassword(db, 'u1', c.id, '')).toThrow(/HQ_SECRET_KEY/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client support data (routes)', () => {
|
||||
function appWith() {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
|
||||
createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
|
||||
const c = 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 = 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<string, unknown> }
|
||||
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' })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue