- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents. - Implement visibility rules for users based on roles (reporters, supervisors). - Create tests for incident reporting functionality to ensure correctness and reliability. - Introduce site and area management routes, including validation for area hierarchy. - Establish user management routes with role-based access control for creating and updating users. - Add setup routes for initializing the application with an organization and admin user. - Implement shared enums and schemas for consistent validation and data handling across the application. - Create a test helper for setting up an in-memory database and API client for testing. - Add TypeScript configuration for server-side code with strict type checking and module resolution.
92 lines
4.4 KiB
TypeScript
92 lines
4.4 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { ADMIN, createTestContext } from '../test/helpers.ts'
|
|
|
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
|
beforeAll(async () => {
|
|
ctx = await createTestContext()
|
|
})
|
|
afterAll(() => ctx.close())
|
|
|
|
describe('first-run setup', () => {
|
|
it('reports that setup is done and refuses to run again', async () => {
|
|
const client = ctx.anonymous()
|
|
expect((await client.get('/api/setup')).body).toEqual({ needsSetup: false })
|
|
const again = await client.post('/api/setup', { ...ADMIN, email: 'intruder@example.com' })
|
|
expect(again.status).toBe(409)
|
|
})
|
|
|
|
it('signed the new admin in', async () => {
|
|
const me = await ctx.admin.get('/api/auth/me')
|
|
expect(me.status).toBe(200)
|
|
expect(me.body.user).toMatchObject({ email: ADMIN.email, role: 'admin' })
|
|
expect(me.body.organization.name).toBe(ADMIN.organizationName)
|
|
expect(me.body.user.passwordHash).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('sign in and out', () => {
|
|
it('rejects a wrong password with a generic message', async () => {
|
|
const res = await ctx.anonymous().post('/api/auth/login', { email: ADMIN.email, password: 'nope' })
|
|
expect(res.status).toBe(401)
|
|
expect(res.body.error).toBe('Incorrect email or password')
|
|
})
|
|
|
|
it('treats unknown emails the same as wrong passwords', async () => {
|
|
const res = await ctx.anonymous().post('/api/auth/login', { email: 'nobody@example.com', password: 'whatever' })
|
|
expect(res.status).toBe(401)
|
|
expect(res.body.error).toBe('Incorrect email or password')
|
|
})
|
|
|
|
it('accepts email in any case and sets an http-only cookie', async () => {
|
|
const client = ctx.anonymous()
|
|
const res = await client.post('/api/auth/login', { email: ADMIN.email.toUpperCase(), password: ADMIN.password })
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('set-cookie')).toMatch(/HttpOnly/)
|
|
expect(res.headers.get('set-cookie')).toMatch(/SameSite=Lax/)
|
|
expect((await client.get('/api/auth/me')).status).toBe(200)
|
|
|
|
expect((await client.post('/api/auth/logout')).status).toBe(204)
|
|
expect((await client.get('/api/auth/me')).status).toBe(401)
|
|
})
|
|
|
|
it('invalidates the session server-side on logout, even if the cookie is replayed', async () => {
|
|
const client = ctx.anonymous()
|
|
await client.post('/api/auth/login', { email: ADMIN.email, password: ADMIN.password })
|
|
const stolen = client.cookie
|
|
await client.post('/api/auth/logout')
|
|
client.cookie = stolen
|
|
expect((await client.get('/api/auth/me')).status).toBe(401)
|
|
})
|
|
|
|
it('rate-limits repeated attempts for one email', async () => {
|
|
const client = ctx.anonymous()
|
|
const email = 'ratelimit@example.com'
|
|
for (let i = 0; i < 10; i++) expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(401)
|
|
expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(429)
|
|
})
|
|
|
|
it('blocks deactivated users and ends their sessions', async () => {
|
|
const worker = await ctx.createUser('reporter', 'leaver@example.com')
|
|
expect((await worker.get('/api/auth/me')).status).toBe(200)
|
|
await ctx.admin.patch(`/api/users/${worker.user.id}`, { active: false })
|
|
expect((await worker.get('/api/auth/me')).status).toBe(401)
|
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'leaver@example.com', password: 'a sufficiently long password' })).status).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe('change password', () => {
|
|
it('requires the current password and signs out other sessions', async () => {
|
|
const user = await ctx.createUser('supervisor', 'changer@example.com')
|
|
const otherDevice = ctx.anonymous()
|
|
await otherDevice.post('/api/auth/login', { email: 'changer@example.com', password: 'a sufficiently long password' })
|
|
|
|
expect((await user.post('/api/auth/password', { currentPassword: 'wrong', newPassword: 'a brand new password' })).status).toBe(400)
|
|
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'short' })).status).toBe(400)
|
|
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'a brand new password' })).status).toBe(204)
|
|
|
|
expect((await user.get('/api/auth/me')).status).toBe(200)
|
|
expect((await otherDevice.get('/api/auth/me')).status).toBe(401)
|
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'changer@example.com', password: 'a brand new password' })).status).toBe(200)
|
|
})
|
|
})
|