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/employees.test.ts

159 lines
8.7 KiB
TypeScript

// 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 session = (await login(db, 's@x.co', 'password2'))!
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')).toBeNull()
expect((await getEmployee(db, emp.id))!.active).toBe(false)
await reactivateEmployee(db, ownerId, emp.id)
expect(await login(db, 's@x.co', 'password2')).not.toBeNull()
})
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 session = (await login(db, 's@x.co', 'password2'))!
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')).toBeNull()
expect(await login(db, 's@x.co', 'new-secret-9')).not.toBeNull()
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)
})
})