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

116 lines
5.9 KiB
TypeScript

// 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'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
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<string | null> {
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 = 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<string, unknown> }
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 = 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 = 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 = 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 = 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(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 = 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(verifySession(ctx.db, current)).not.toBeNull() // survives
expect(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 = 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', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
expect(() => changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).toThrow(/8/)
})
it('phone/title round-trip through updateEmployee, and emptying stores NULL', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' })
expect(getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' })
updateEmployee(db, id, id, { phone: '', title: ' ' })
expect(getEmployee(db, id)).toMatchObject({ phone: null, title: null })
})
})