/** * Rev 10: a subscriber is authenticated by a device token, not by an account * and not by a fake user. The client must send it as `X-Device-Token` on every * subscriber call — and it must win over an account token when both exist, * because it is the narrower credential. */ import { configureApi, get, post } from '../src/services/apiClient'; import { ackAlarm, alarmStatus, getAlert, joinAlert, qrImage } from '../src/services/alarmApi'; const g = globalThis as unknown as { fetch: typeof fetch }; const realFetch = g.fetch; interface Call { url: string; headers: Record; body?: string; } let calls: Call[] = []; function stub(status = 200, body: unknown = {}): void { calls = []; g.fetch = (async (url: string, init: RequestInit) => { calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record, body: typeof init?.body === 'string' ? init.body : undefined, }); return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body, }; }) as unknown as typeof fetch; } beforeEach(() => { configureApi({ baseUrl: 'http://instance.test', token: 'account-token', refreshToken: null, onUnauthorized: () => {}, onSessionUpdate: () => {}, }); }); afterEach(() => { g.fetch = realFetch; }); describe('device token transport', () => { it('is sent as X-Device-Token and replaces the account token', async () => { stub(200, {}); await get('/action/getAlert?alertId=a', 'dev-token'); expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); expect(calls[0].headers['X-Access-Token']).toBeUndefined(); }); it('is omitted (and the account token used) when not supplied', async () => { stub(200, {}); await get('/action/getAlert?alertId=a'); expect(calls[0].headers['X-Device-Token']).toBeUndefined(); expect(calls[0].headers['X-Access-Token']).toBe('account-token'); }); it('survives a request with no session at all', async () => { configureApi({ token: null, refreshToken: null }); stub(200, {}); await post('/action/ackAlarm', { alertId: 'a', eventId: 'e' }, false, 'dev-token'); expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); expect(calls[0].headers['X-Access-Token']).toBeUndefined(); }); }); describe('subscriber calls use the device token', () => { it('getAlert passes it through', async () => { stub(200, { alertId: 'a' }); await getAlert('a', 'dev-token'); expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); }); it('alarmStatus passes it through', async () => { stub(200, { active: false }); await alarmStatus('a', undefined, 'dev-token'); expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); }); it('ackAlarm passes it through and omits deviceId when the token names the row', async () => { stub(200, { acked: 1 }); await ackAlarm({ alertId: 'a', eventId: 'e', deviceToken: 'dev-token' }); expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); expect(calls[0].url).not.toContain('deviceId='); }); it('qrImage passes it through', async () => { g.fetch = (async (url: string, init: RequestInit) => { calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record }); const blob = { data: 'png' }; return { ok: true, status: 200, blob: async () => blob, }; }) as unknown as typeof fetch; const RealReader = (globalThis as { FileReader?: unknown }).FileReader; class FakeReader { result: string | null = null; onload: (() => void) | null = null; onerror: (() => void) | null = null; readAsDataURL() { this.result = 'data:image/png;base64,cG5n'; this.onload?.(); } } (globalThis as { FileReader?: unknown }).FileReader = FakeReader; try { await qrImage('a', 'dev-token'); } finally { (globalThis as { FileReader?: unknown }).FileReader = RealReader; } expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); }); }); describe('joinAlert', () => { it('is the unauthenticated path: no access token, and it returns the device token once', async () => { configureApi({ token: null, refreshToken: null }); stub(200, { id: 'sub-1', deviceToken: 'issued-once' }); const joined = await joinAlert({ alertId: 'a', subscribeToken: 's', deviceId: 'd' }); expect(joined.deviceToken).toBe('issued-once'); expect(calls[0].headers['X-Access-Token']).toBeUndefined(); expect(calls[0].headers['X-Device-Token']).toBeUndefined(); // The payload wrapper the service expects, with no credential fields. expect(JSON.parse(calls[0].body ?? '{}').payload).toMatchObject({ alertId: 'a', deviceId: 'd' }); }); });