feat: implement incident reporting and management system
- 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.
This commit is contained in:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user