// apps/hq/test/profile-me.test.ts — go-live cluster WS-E: self-service profile + own password (D18) import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff, verifySession } from '../src/auth' import { changeOwnPassword, getEmployee, updateEmployee } from '../src/repos-employees' import { listAudit } from '../src/audit' import { apiRouter } from '../src/api' async function appWith() { const db = openDb(':memory:'); await seedIfEmpty(db) await createStaff(db, { email: 'staff@test.in', displayName: 'Priya', role: 'staff', password: 'first-pass-9' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db)) const server = app.listen(0) const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, baseUrl } } async function login(baseUrl: string, email: string, password: string): Promise { const res = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }) if (!res.ok) return null return ((await res.json()) as { token: string }).token } const H = (token: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${token}` }) describe('self-service profile (/me)', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('GET /me returns the caller, without password columns', async () => { const ctx = await appWith(); servers.push(ctx.server) const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const res = await fetch(`${ctx.baseUrl}/me`, { headers: H(token) }) const json = await res.json() as { employee: Record } expect(res.status).toBe(200) expect(json.employee).toMatchObject({ email: 'staff@test.in', displayName: 'Priya', role: 'staff' }) expect(json.employee).not.toHaveProperty('pw_salt') expect(json.employee).not.toHaveProperty('pw_hash') }) it('PATCH /me updates name + phone (audited); role/email in the body are ignored', async () => { const ctx = await appWith(); servers.push(ctx.server) const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: JSON.stringify({ displayName: 'Priya N', phone: '+91 98765', role: 'owner', email: 'evil@x.co' }), }) const json = await res.json() as { employee: { displayName: string; phone: string | null; role: string; email: string } } expect(res.status).toBe(200) expect(json.employee).toMatchObject({ displayName: 'Priya N', phone: '+91 98765', role: 'staff', email: 'staff@test.in', // untouchable via /me }) const audit = (await listAudit(ctx.db)).find((a) => a.action === 'update' && a.entity === 'staff_user') expect(audit).toBeDefined() }) it('PATCH /me with an empty patch is rejected', async () => { const ctx = await appWith(); servers.push(ctx.server) const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: '{}' }) expect(res.status).toBe(400) }) it('POST /me/password rejects a wrong current password and leaves sessions alive', async () => { const ctx = await appWith(); servers.push(ctx.server) const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const res = await fetch(`${ctx.baseUrl}/me/password`, { method: 'POST', headers: H(token), body: JSON.stringify({ currentPassword: 'wrong-guess', newPassword: 'brand-new-pw-9' }), }) expect(res.status).toBe(400) expect(await verifySession(ctx.db, token)).not.toBeNull() // unchanged expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).not.toBeNull() // old pw still works }) it('POST /me/password with the right current password: other sessions die, THIS one survives', async () => { const ctx = await appWith(); servers.push(ctx.server) const other = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // e.g. old laptop const current = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // the one changing it const res = await fetch(`${ctx.baseUrl}/me/password`, { method: 'POST', headers: H(current), body: JSON.stringify({ currentPassword: 'first-pass-9', newPassword: 'brand-new-pw-9' }), }) expect(res.status).toBe(200) expect(await verifySession(ctx.db, current)).not.toBeNull() // survives expect(await verifySession(ctx.db, other)).toBeNull() // dead with the old credential expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).toBeNull() expect(await login(ctx.baseUrl, 'staff@test.in', 'brand-new-pw-9')).not.toBeNull() const audit = (await listAudit(ctx.db)).find((a) => a.action === 'change_own_password') expect(audit).toBeDefined() expect(audit!.after_json ?? '').not.toMatch(/hash/) }) it('short new passwords are rejected in the repo', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) await expect(changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).rejects.toThrow(/8/) }) it('phone/title round-trip through updateEmployee, and emptying stores NULL', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) await updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' }) expect(await getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' }) await updateEmployee(db, id, id, { phone: '', title: ' ' }) expect(await getEmployee(db, id)).toMatchObject({ phone: null, title: null }) }) })