// apps/hq/test/client-owner.test.ts — Phase 3: owner plumbing. // client.owner_id (additive column), setClientOwner (audited), the ownerScope // role gate (staff forced to self), and PATCH /clients/:id/owner (owner/manager only). import Database from 'better-sqlite3' import express from 'express' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { describe, it, expect, afterAll, beforeAll } from 'vitest' import { openDb, type DB } from '../src/db' import { createStaff, ownerScope } from '../src/auth' import { createEmployee, deactivateEmployee } from '../src/repos-employees' import { createClient, getClient, listClients, setClientOwner } from '../src/repos-clients' import { listAudit } from '../src/audit' import { apiRouter } from '../src/api' async function withOwner(): Promise<{ db: DB; ownerId: string }> { const db = openDb(':memory:') const { id } = await createStaff(db, { email: 'admin@sims.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9', }) return { db, ownerId: id } } describe('client.owner_id column', () => { it('exists on a fresh DB (SCHEMA born correct) and defaults to NULL', async () => { const db = openDb(':memory:') const cols = (await db.all<{ name: string }>(`PRAGMA table_info(client)`)).map((c) => c.name) expect(cols).toContain('owner_id') const c = await createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' }) expect((await getClient(db, c.id))!.ownerId).toBeUndefined() }) it('migrate() adds owner_id to a pre-Phase-3 DB, preserving rows, idempotently', async () => { // Simulate a DB created before this phase: client table without owner_id + one row. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-owner-')) const raw = new Database(path.join(dir, 'hq.db')) raw.exec(`CREATE TABLE client ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '', contacts TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')), notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL )`) raw.prepare( `INSERT INTO client (id, code, name, created_at) VALUES ('c1', 'CL0001', 'Old Row', '2026-01-01T00:00:00Z')`, ).run() raw.close() const db = openDb(dir) // exec SCHEMA (IF NOT EXISTS skips client) + migrate() adds the column const cols = (await db.all<{ name: string }>(`PRAGMA table_info(client)`)).map((c) => c.name) expect(cols).toContain('owner_id') const kept = await db.get(`SELECT id, name, owner_id FROM client WHERE id='c1'`) expect(kept).toMatchObject({ id: 'c1', name: 'Old Row', owner_id: null }) await db.close() const again = openDb(dir) // idempotent re-run loses nothing expect(await again.get(`SELECT COUNT(*) AS n FROM client`)).toMatchObject({ n: 1 }) await again.close() fs.rmSync(dir, { recursive: true, force: true }) }) }) describe('setClientOwner', () => { it('gives a lead a routable owner and writes an audit row in the same transaction', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const lead = await createClient(db, ownerId, { name: 'Nagari Sah.', stateCode: '32', status: 'lead' }) const after = await setClientOwner(db, ownerId, lead.id, emp.id) expect(after.ownerId).toBe(emp.id) expect((await getClient(db, lead.id))!.ownerId).toBe(emp.id) expect((await listClients(db)).find((c) => c.id === lead.id)!.ownerId).toBe(emp.id) const audit = (await listAudit(db)).find((a) => a.action === 'set_owner' && a.entity === 'client' && a.entity_id === lead.id) expect(audit).toBeDefined() expect(JSON.parse(audit!.before_json!)).not.toHaveProperty('ownerId') expect(JSON.parse(audit!.after_json!)).toMatchObject({ ownerId: emp.id }) }) it('clears the owner with null, audited', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const c = await createClient(db, ownerId, { name: 'Clearable', stateCode: '32' }) await setClientOwner(db, ownerId, c.id, emp.id) const cleared = await setClientOwner(db, ownerId, c.id, null) expect(cleared.ownerId).toBeUndefined() const audits = (await listAudit(db)).filter((a) => a.action === 'set_owner' && a.entity_id === c.id) expect(audits).toHaveLength(2) }) it('rejects an unknown employee and an inactive employee; unknown client 404s', async () => { const { db, ownerId } = await withOwner() const c = await createClient(db, ownerId, { name: 'Guarded', stateCode: '32' }) await expect(setClientOwner(db, ownerId, c.id, 'nope')).rejects.toThrow(/employee/i) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) await deactivateEmployee(db, ownerId, emp.id) await expect(setClientOwner(db, ownerId, c.id, emp.id)).rejects.toThrow(/inactive/i) await expect(setClientOwner(db, ownerId, 'missing', null)).rejects.toThrow(/client/i) // failed sets leave no owner and no audit row behind expect((await getClient(db, c.id))!.ownerId).toBeUndefined() expect((await listAudit(db)).filter((a) => a.action === 'set_owner')).toHaveLength(0) }) }) describe('ownerScope role gate', () => { it('forces staff to self and ignores any widening param', () => { expect(ownerScope({ id: 'me', role: 'staff' })).toBe('me') expect(ownerScope({ id: 'me', role: 'staff' }, 'someone-else')).toBe('me') }) it('owner and manager are unfiltered by default and may narrow', () => { expect(ownerScope({ id: 'o1', role: 'owner' })).toBeUndefined() expect(ownerScope({ id: 'm1', role: 'manager' })).toBeUndefined() expect(ownerScope({ id: 'o1', role: 'owner' }, 'e9')).toBe('e9') expect(ownerScope({ id: 'm1', role: 'manager' }, 'e9')).toBe('e9') expect(ownerScope({ id: 'm1', role: 'manager' }, '')).toBeUndefined() // empty ≠ a filter }) }) describe('PATCH /clients/:id/owner', () => { const db = openDb(':memory:') let staffDbId = '' beforeAll(async () => { await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'manager@test.in', displayName: 'Mgr', role: 'manager', password: 'manager-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) staffDbId = ((await db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='staff@test.in'`)) as { id: string }).id }) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const server = app.listen(0) const base = `http://localhost:${(server.address() as { port: number }).port}/api` afterAll(() => server.close()) const tokenOf = async (email: string, password: string) => (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json() as { token: string }).token const patchOwner = async (token: string, clientId: string, body: unknown) => { const res = await fetch(`${base}/clients/${clientId}/owner`, { method: 'PATCH', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body), }) return { status: res.status, json: await res.json() as any } } it('owner and manager can set an owner; the write is audited; staff gets 403', async () => { const owner = await tokenOf('owner@test.in', 'owner-password') const c = await createClient(db, 'seed', { name: 'Routable Lead', stateCode: '32', status: 'lead' }) const set = await patchOwner(owner, c.id, { ownerId: staffDbId }) expect(set.status).toBe(200) expect(set.json.client.ownerId).toBe(staffDbId) const audit = (await listAudit(db)).find((a) => a.action === 'set_owner' && a.entity_id === c.id) expect(audit).toBeDefined() const manager = await tokenOf('manager@test.in', 'manager-password') const cleared = await patchOwner(manager, c.id, { ownerId: null }) expect(cleared.status).toBe(200) expect(cleared.json.client.ownerId).toBeUndefined() const staff = await tokenOf('staff@test.in', 'staff-password') const denied = await patchOwner(staff, c.id, { ownerId: staffDbId }) expect(denied.status).toBe(403) expect((await getClient(db, c.id))!.ownerId).toBeUndefined() // staff write did not land }) it('404s an unknown client; 400s a bad employee id or missing ownerId field', async () => { const owner = await tokenOf('owner@test.in', 'owner-password') expect((await patchOwner(owner, 'missing', { ownerId: staffDbId })).status).toBe(404) const c = await createClient(db, 'seed', { name: 'Bad Input Co', stateCode: '32' }) expect((await patchOwner(owner, c.id, { ownerId: 'not-an-employee' })).status).toBe(400) expect((await patchOwner(owner, c.id, {})).status).toBe(400) }) })