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

151 lines
7.4 KiB
TypeScript

// apps/hq/test/employees-routes.test.ts — Phase 2: /employees API surface.
// Mutations are owner-only; GET is any signed-in user (name/owner picker).
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff, login } from '../src/auth'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staffer', role: 'staff', password: 'staff-password' })
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`
return { db, server, base }
}
describe('/employees routes', () => {
const ctx = appWith()
afterAll(() => ctx.server.close())
const tokenOf = async (email: string, password: string) =>
(await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
const call = async (token: string | null, method: string, path: string, body?: unknown) => {
const res = await fetch(`${ctx.base}${path}`, {
method,
headers: { 'content-type': 'application/json', ...(token !== null ? { authorization: `Bearer ${token}` } : {}) },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
return { status: res.status, json: await res.json() as any }
}
it('GET /employees requires auth', async () => {
expect((await call(null, 'GET', '/employees')).status).toBe(401)
})
it('GET /employees works for any signed-in user (staff picker) and returns all rows + total', async () => {
const token = await tokenOf('staff@test.in', 'staff-password')
const out = await call(token, 'GET', '/employees')
expect(out.status).toBe(200)
expect(out.json.ok).toBe(true)
// seed owner + test owner + staffer — every row present, no silent truncation
expect(out.json.employees.length).toBeGreaterThanOrEqual(3)
expect(out.json.total).toBe(out.json.employees.length)
for (const e of out.json.employees) {
expect(e).not.toHaveProperty('pw_salt')
expect(e).not.toHaveProperty('pw_hash')
}
})
it('owner CRUD round-trips: create → list → patch → deactivate → reactivate → password', async () => {
const token = await tokenOf('owner@test.in', 'owner-password')
const created = await call(token, 'POST', '/employees', {
email: 'New@Test.in', displayName: 'Newbie', role: 'manager', password: 'password-9',
})
expect(created.status).toBe(200)
expect(created.json.employee).toMatchObject({ email: 'new@test.in', displayName: 'Newbie', role: 'manager', active: true })
const id = created.json.employee.id as string
const listed = await call(token, 'GET', '/employees')
expect(listed.json.employees.some((e: { id: string }) => e.id === id)).toBe(true)
const patched = await call(token, 'PATCH', `/employees/${id}`, { displayName: 'Renamed', role: 'staff' })
expect(patched.status).toBe(200)
expect(patched.json.employee).toMatchObject({ id, displayName: 'Renamed', role: 'staff' })
const deactivated = await call(token, 'POST', `/employees/${id}/deactivate`)
expect(deactivated.status).toBe(200)
expect(deactivated.json.employee.active).toBe(false)
const reactivated = await call(token, 'POST', `/employees/${id}/reactivate`)
expect(reactivated.status).toBe(200)
expect(reactivated.json.employee.active).toBe(true)
const pw = await call(token, 'POST', `/employees/${id}/password`, { password: 'brand-new-secret' })
expect(pw.status).toBe(200)
expect(login(ctx.db, 'new@test.in', 'password-9')).toBeNull()
expect(login(ctx.db, 'new@test.in', 'brand-new-secret')).not.toBeNull()
})
it('non-owner gets 403 on every mutation route', async () => {
const owner = await tokenOf('owner@test.in', 'owner-password')
const target = (await call(owner, 'POST', '/employees', {
email: 'victim@test.in', displayName: 'Victim', role: 'staff', password: 'password-9',
})).json.employee.id as string
const token = await tokenOf('staff@test.in', 'staff-password')
const attempts = [
call(token, 'POST', '/employees', { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'password-9' }),
call(token, 'PATCH', `/employees/${target}`, { displayName: 'Hacked' }),
call(token, 'POST', `/employees/${target}/deactivate`),
call(token, 'POST', `/employees/${target}/reactivate`),
call(token, 'POST', `/employees/${target}/password`, { password: 'password-hacked' }),
]
for (const attempt of await Promise.all(attempts)) expect(attempt.status).toBe(403)
})
it('repo validation surfaces as 400; unknown id as 404', async () => {
const token = await tokenOf('owner@test.in', 'owner-password')
const badRole = await call(token, 'POST', '/employees', {
email: 'y@x.co', displayName: 'Y', role: 'admin', password: 'password-9',
})
expect(badRole.status).toBe(400)
expect(badRole.json.error).toMatch(/role/i)
const shortPw = await call(token, 'POST', '/employees', {
email: 'y@x.co', displayName: 'Y', role: 'staff', password: 'short',
})
expect(shortPw.status).toBe(400)
expect((await call(token, 'PATCH', '/employees/nope', { displayName: 'Z' })).status).toBe(404)
expect((await call(token, 'POST', '/employees/nope/deactivate')).status).toBe(404)
expect((await call(token, 'POST', '/employees/nope/reactivate')).status).toBe(404)
expect((await call(token, 'POST', '/employees/nope/password', { password: 'password-9' })).status).toBe(404)
})
it('PATCH with no recognized fields is a 400, not a silent 200 no-op', async () => {
const token = await tokenOf('owner@test.in', 'owner-password')
const created = await call(token, 'POST', '/employees', {
email: 'noop@test.in', displayName: 'Noop', role: 'staff', password: 'password-9',
})
const id = created.json.employee.id as string
expect((await call(token, 'PATCH', `/employees/${id}`, {})).status).toBe(400)
// wrongly-typed field is dropped by the route → resolves to an empty patch → 400
expect((await call(token, 'PATCH', `/employees/${id}`, { role: 123 })).status).toBe(400)
// and the employee is untouched
const after = await call(token, 'GET', '/employees')
expect(after.json.employees.find((e: { id: string }) => e.id === id)).toMatchObject({ displayName: 'Noop', role: 'staff' })
})
it('mutations act as the signed-in user (audit userId = actor)', async () => {
const token = await tokenOf('owner@test.in', 'owner-password')
const created = await call(token, 'POST', '/employees', {
email: 'audited@test.in', displayName: 'Audited', role: 'staff', password: 'password-9',
})
const id = created.json.employee.id as string
const ownerId = (ctx.db.prepare(`SELECT id FROM staff_user WHERE email='owner@test.in'`).get() as { id: string }).id
const row = ctx.db.prepare(
`SELECT user_id FROM audit_log WHERE entity='staff_user' AND entity_id=? AND action='create'`,
).get(id) as { user_id: string }
expect(row.user_id).toBe(ownerId)
})
})