// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createTestContext, hoursAgo } from '../test/helpers.ts' let ctx: Awaited> let north: { id: string } let south: { id: string } const json = (res: Response): Promise => res.json() const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]) /** Submits like the kiosk page: multipart with the token header. */ function kioskReport(token: string, report: Record, files: File[] = []) { const form = new FormData() form.append('report', JSON.stringify(report)) for (const file of files) form.append('files', file) return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form }) } const report = (overrides: Record = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides }) beforeAll(async () => { ctx = await createTestContext() north = await ctx.createSite('North') south = await ctx.createSite('South') }) afterAll(() => ctx.close()) describe('reporting links', () => { it('are managed by safety managers', async () => { const supervisor = await ctx.createUser('supervisor') expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403) const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' }) expect(res.status).toBe(201) expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/) expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet') }) it('let anyone with the token file a report, with photos, and nothing more', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }) expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } }) const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })]) expect(res.status).toBe(201) const { reference, failedFiles } = await json(res) expect(reference).toMatch(/^INC-\d{4}$/) expect(failedFiles).toEqual([]) const number = Number(reference.slice(4)) const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false }) expect(incident.attachments).toHaveLength(1) // The token is not a session expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401) }) it('treat reports without a name as anonymous', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body const { reference } = await json(await kioskReport(kiosk.token, report())) const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident expect(incident).toMatchObject({ anonymous: true, reporterName: null }) }) it('stop working when revoked', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true }) expect((await kioskReport(kiosk.token, report())).status).toBe(401) expect((await kioskReport('made-up-token', report())).status).toBe(401) }) it('can be limited to one site', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })) expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id]) expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400) }) it('validate reports', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body const res = await kioskReport(kiosk.token, report({ type: 'explosion' })) expect(res.status).toBe(400) expect((await json(res)).issues[0].path).toBe('type') }) it('are rate limited', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429) }) }) describe('resubmitting the same report (offline retries)', () => { it('returns the original instead of creating a duplicate', async () => { const reporter = await ctx.createUser('reporter') const clientId = crypto.randomUUID() const first = await reporter.post('/api/incidents', { ...report(), clientId }) const again = await reporter.post('/api/incidents', { ...report(), clientId }) expect(first.status).toBe(201) expect(again.status).toBe(200) expect(again.body.incident.number).toBe(first.body.incident.number) }) it('holds under simultaneous retries', async () => { const reporter = await ctx.createUser('reporter', 'racer@example.com') const clientId = crypto.randomUUID() const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId }))) expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1) }) it('does not attach photos twice from a kiosk retry', async () => { const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body const clientId = crypto.randomUUID() const file = () => new File([PNG], 'p.png', { type: 'image/png' }) const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()])) const b = await kioskReport(kiosk.token, report({ clientId }), [file()]) expect(b.status).toBe(200) expect((await json(b)).reference).toBe(a.reference) const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident expect(incident.attachments).toHaveLength(1) }) })