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.
115 lines
6.4 KiB
TypeScript
115 lines
6.4 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('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' })
|
|
})
|
|
})
|