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.
sims-hq/apps/hq/test/client-support.test.ts

124 lines
7.0 KiB
TypeScript

// 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: 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<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' })
})
})