diff --git a/App.tsx b/App.tsx index 456919c..4901d43 100644 --- a/App.tsx +++ b/App.tsx @@ -132,11 +132,9 @@ function Root() { useEffect(() => { if (token === prevToken.current) return; prevToken.current = token; - if (token) { - navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); - } else { - navigation.reset({ index: 0, routes: [{ name: 'Login' }] }); - } + // Home is always reachable: without an account it is the subscriber view + // (rev 10). Login is an explicit destination, never a wall. + navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); }, [token, navigation]); useNativeLaunchTakeover(); @@ -144,7 +142,7 @@ function Root() { if (booting) return ; return ( - + ); } diff --git a/__tests__/deviceToken.test.ts b/__tests__/deviceToken.test.ts new file mode 100644 index 0000000..c80367e --- /dev/null +++ b/__tests__/deviceToken.test.ts @@ -0,0 +1,137 @@ +/** + * 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' }); + }); +}); diff --git a/src/screens/AlertDetailScreen.tsx b/src/screens/AlertDetailScreen.tsx index 81e739a..8532b23 100644 --- a/src/screens/AlertDetailScreen.tsx +++ b/src/screens/AlertDetailScreen.tsx @@ -255,7 +255,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | setNotice('The service URL is not known yet — re-open the alert in a moment.'); return; } - const wsUrl = alarmSocketUrl(resolved, alertId, token); + const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken); if (!wsUrl) { setNotice( 'No instance address is configured, so background listening is unavailable.', @@ -269,7 +269,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | ? 'Listening again — this device is armed.' : `Could not start listening (${res.reason ?? 'unknown'}).`, ); - }, [alertId, getAlert, refreshListening, resolved]); + }, [alert?.deviceToken, alertId, getAlert, refreshListening, resolved]); const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions( isAdmin ? alertId : null, @@ -279,7 +279,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | // Read the authoritative state on mount; the local copy is only a cache. const loadServerAlert = useCallback(async () => { try { - const fresh = await api.getAlert(alertId); + const fresh = await api.getAlert(alertId, alert?.deviceToken); setServerAlert(fresh); updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message }); } catch (e) { @@ -296,7 +296,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | } // Offline is fine — the cached copy renders. } - }, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]); + }, [ + alert?.deviceToken, + alert?.expiresAt, + alertId, + markEnded, + serverAlert?.expiresAt, + updateAlertLocal, + ]); useEffect(() => { setActiveAlertId(isAdmin ? null : alertId); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index b31fa84..10ab6ff 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -68,7 +68,7 @@ const ENDING_COPY: Record['lastEnding']> export default function HomeScreen() { const navigation = useNavigation