feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
This commit is contained in:
jayjennings committed 2026-10-06 11:20:44 -07:00
1 parent 708da6b9f7
commit 3f6dff5f24
94 files changed
+7213 -643

No files matched your search

+47 -12
View File
@@ -108,27 +108,62 @@ describe('listing', () => {
})
describe('updating', () => {
it('lets supervisors update and close incidents, recording what changed', async () => {
it('lets supervisors edit details, recording what changed', async () => {
const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
const number = created.body.incident.number
expect((await reporter.patch(`/api/incidents/${number}`, { status: 'closed' })).status).toBe(403)
expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403)
const closed = await supervisor.patch(`/api/incidents/${number}`, { status: 'closed', severity: 'low' })
expect(closed.status).toBe(200)
expect(closed.body.incident.status).toBe('closed')
expect(closed.body.incident.closedAt).not.toBeNull()
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
expect(edited.status).toBe(200)
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
})
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
expect(reopened.body.incident.closedAt).toBeNull()
it('does not change status through edits', async () => {
const created = await reporter.post('/api/incidents', report())
const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' })
expect(res.body.incident.status).toBe('reported')
})
it('clears the area when moving an incident to another site', async () => {
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
const south = await ctx.createSite('West Campus')
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id })
expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null })
const west = await ctx.createSite('West Campus')
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null })
})
})
describe('notifications', () => {
it('alerts safety managers about high-severity reports, not low ones', async () => {
const manager = await ctx.createUser('safety_manager', 'alerts@example.com')
await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' }))
await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' }))
await ctx.notifier.flush()
const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email)
expect(mails).toHaveLength(1)
expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/)
expect(mails[0].text).toContain('http://safety.test/incidents/')
})
})
describe('export', () => {
it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => {
await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' }))
const res = await supervisor.get('/api/incidents/export?type=environmental')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/csv')
const csv = res.body.toString('utf8')
const lines = csv.trimEnd().split('\r\n')
expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/)
expect(lines).toHaveLength(2)
expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`)
})
it('only exports what the user can see', async () => {
const other = await ctx.createUser('reporter', 'csv-reporter@example.com')
const res = await other.get('/api/incidents/export')
expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1)
})
})