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-owner.test.ts

174 lines
9.2 KiB
TypeScript

// 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@tecnostac.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)
})
})