// 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' function withOwner(): { db: DB; ownerId: string } { const db = openDb(':memory:') const { id } = 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', () => { const { db, ownerId } = withOwner() createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' }) createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const all = 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', () => { const { db, ownerId } = withOwner() expect(() => createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }), ).toThrow(/role/i) expect(() => createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }), ).toThrow(/8/) }) it('deactivation kills the live session immediately and purges its rows', () => { const { db, ownerId } = withOwner() const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const session = login(db, 's@x.co', 'password2')! expect(verifySession(db, session.token)).toMatchObject({ id: emp.id }) deactivateEmployee(db, ownerId, emp.id) expect(verifySession(db, session.token)).toBeNull() const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number } expect(rows.n).toBe(0) }) it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', () => { const { db, ownerId } = withOwner() expect(() => updateEmployee(db, ownerId, ownerId, { role: 'staff' })).toThrow(/last active owner/) expect(() => deactivateEmployee(db, ownerId, ownerId)).toThrow(/yourself/) // second owner unlocks the demotion of the first const second = createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' }) expect(updateEmployee(db, second.id, ownerId, { role: 'manager' }).role).toBe('manager') }) it('deactivate/reactivate round-trips and login respects active', () => { const { db, ownerId } = withOwner() const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) deactivateEmployee(db, ownerId, emp.id) expect(login(db, 's@x.co', 'password2')).toBeNull() expect(getEmployee(db, emp.id)!.active).toBe(false) reactivateEmployee(db, ownerId, emp.id) expect(login(db, 's@x.co', 'password2')).not.toBeNull() }) it('password reset works and is audited without the hash', () => { const { db, ownerId } = withOwner() const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') expect(login(db, 's@x.co', 'password2')).toBeNull() expect(login(db, 's@x.co', 'new-secret-9')).not.toBeNull() const audit = 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', () => { const { db, ownerId } = withOwner() const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' }) deactivateEmployee(db, ownerId, emp.id) reactivateEmployee(db, ownerId, emp.id) const actions = 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)', () => { const { db, ownerId } = withOwner() const m = createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' }) expect(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:') as DB 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) }) })