// apps/hq/test/employees.test.ts — Phase 1: employee foundation (D16) import Database from 'better-sqlite3' import { describe, it, expect } from 'vitest' import { openDb, rebuildStaffUserRoleCheck, type DB } from '../src/db' import { createStaff, login, verifySession } from '../src/auth' import { createEmployee, deactivateEmployee, getEmployee, listEmployees, reactivateEmployee, setEmployeePassword, updateEmployee, } from '../src/repos-employees' import { listAudit } from '../src/audit' 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('employee foundation', () => { it('creates manager and staff; list never exposes password columns', async () => { const { db, ownerId } = await withOwner() await createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' }) await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const all = await listEmployees(db) expect(all).toHaveLength(3) expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff']) expect(all.find((e) => e.displayName === 'Mgr')!.email).toBe('m@x.co') // lowercased for (const e of all) { expect(e).not.toHaveProperty('pw_salt') expect(e).not.toHaveProperty('pw_hash') } }) it('rejects a bad role and a short password in the repo', async () => { const { db, ownerId } = await withOwner() await expect( createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }), ).rejects.toThrow(/role/i) await expect( createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }), ).rejects.toThrow(/8/) }) it('deactivation kills the live session immediately and purges its rows', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const login2 = await login(db, 's@x.co', 'password2'); if (!login2.ok) throw new Error('login failed'); const session = login2 expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id }) await deactivateEmployee(db, ownerId, emp.id) expect(await verifySession(db, session.token)).toBeNull() const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))! expect(rows.n).toBe(0) }) it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', async () => { const { db, ownerId } = await withOwner() await expect(updateEmployee(db, ownerId, ownerId, { role: 'staff' })).rejects.toThrow(/last active owner/) await expect(deactivateEmployee(db, ownerId, ownerId)).rejects.toThrow(/yourself/) // second owner unlocks the demotion of the first const second = await createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' }) expect((await updateEmployee(db, second.id, ownerId, { role: 'manager' })).role).toBe('manager') }) it('deactivate/reactivate round-trips and login respects active', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) await deactivateEmployee(db, ownerId, emp.id) expect((await login(db, 's@x.co', 'password2')).ok).toBe(false) expect((await getEmployee(db, emp.id))!.active).toBe(false) await reactivateEmployee(db, ownerId, emp.id) expect((await login(db, 's@x.co', 'password2')).ok).toBe(true) }) it('password reset invalidates existing sessions immediately (compromised-credential lockout)', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const login2 = await login(db, 's@x.co', 'password2'); if (!login2.ok) throw new Error('login failed'); const session = login2 expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id }) await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') expect(await verifySession(db, session.token)).toBeNull() const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))! expect(rows.n).toBe(0) }) it('rejects a duplicate email with a friendly, engine-neutral message', async () => { const { db, ownerId } = await withOwner() await createEmployee(db, ownerId, { email: 'dup@x.co', displayName: 'A', role: 'staff', password: 'password1' }) await expect( createEmployee(db, ownerId, { email: 'Dup@X.co', displayName: 'B', role: 'staff', password: 'password2' }), ).rejects.toThrow(/already in use/i) // not the raw 'UNIQUE constraint failed: …' SQLite text }) it('rejects an empty patch instead of writing a no-op audit row', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const auditBefore = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length await expect(updateEmployee(db, ownerId, emp.id, {})).rejects.toThrow(/nothing to update/i) const auditAfter = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length expect(auditAfter).toBe(auditBefore) }) it('password reset works and is audited without the hash', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') expect((await login(db, 's@x.co', 'password2')).ok).toBe(false) expect((await login(db, 's@x.co', 'new-secret-9')).ok).toBe(true) const audit = (await listAudit(db)).find((a) => a.action === 'reset_password' && a.entity_id === emp.id) expect(audit).toBeDefined() expect(audit!.before_json ?? '').not.toMatch(/hash/) expect(audit!.after_json ?? '').not.toMatch(/hash/) }) it('every employee mutation writes an audit row', async () => { const { db, ownerId } = await withOwner() const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) await updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' }) await deactivateEmployee(db, ownerId, emp.id) await reactivateEmployee(db, ownerId, emp.id) const actions = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action) expect(actions).toEqual(expect.arrayContaining(['create', 'update', 'deactivate', 'reactivate'])) }) it('fresh DBs accept manager directly (SCHEMA born correct)', async () => { const { db, ownerId } = await withOwner() const m = await createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' }) expect((await getEmployee(db, m.id))!.role).toBe('manager') }) it('rebuild migrates an old-CHECK DB preserving rows, and is idempotent', () => { // Simulate a DB created before the employee slice: old two-role CHECK + one row. const raw = new Database(':memory:') raw.exec(`CREATE TABLE staff_user ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner','staff')), pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 )`) raw.prepare( `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, ).run('u1', 'a@b.c', 'A', 'owner', 's', 'h') // Old CHECK rejects manager expect(() => raw.prepare( `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, ).run('u2', 'm@b.c', 'M', 'manager', 's', 'h')).toThrow() rebuildStaffUserRoleCheck(raw) // Data preserved, manager now accepted const kept = raw.prepare(`SELECT id, email, role FROM staff_user WHERE id='u1'`).get() as { id: string } expect(kept).toMatchObject({ id: 'u1', email: 'a@b.c', role: 'owner' }) raw.prepare( `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, ).run('u2', 'm@b.c', 'M', 'manager', 's', 'h') // Idempotent: second run is a no-op and loses nothing rebuildStaffUserRoleCheck(raw) const n = raw.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number } expect(n.n).toBe(2) }) })