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.
159 lines
8.9 KiB
TypeScript
159 lines
8.9 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@sims.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)
|
|
})
|
|
})
|