/** * test/push-fcm.test.ts (A33) — FCM v1 sender + device-token registry (Android). * * Unit layer (no network, fake FcmTransport): * - loadFcmConfig: env group all-or-disabled, defaults, host validation * - normalizeFcmToken: loose base64url+':' accept, empty/oversized/bad-charset reject * - FcmTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load * - validateFcmKeyFile: shape check, never logs key material * - createFcmService: DATA-ONLY minimized payload NEEDS-INPUT vs DONE (priority/ * ttl — no `notification` block, cwd/command NEVER in the payload), DND/ * NOTIFY_DONE gates, prune on UNREGISTERED / INVALID_ARGUMENT, connection * errors never throw, token/key never logged * * Integration layer (real startServer on an ephemeral port; google-auth-library * mocked so the default transport never touches Google): * - disabled mode: no FCM_* env → routes absent (404), startup never crashes * - POST/DELETE /push/fcm-token: Origin guard 403, invalid 400, idempotent * 204s (case-preserved), rate limit 429 (frozen wire shape) * - coexistence (PTY): held gate → web-push AND FCM both fire, carrying the * SAME capability token; the data-only payload has no notification block */ import net from 'node:net' import os from 'node:os' import path from 'node:path' import fs from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import * as nodePty from 'node-pty' import { loadConfig } from '../src/config.js' import { startServer } from '../src/server.js' import type { Config, NotifyService, Session } from '../src/types.js' import { createFcmService, initFcm, loadFcmConfig, loadFcmTokenStore, normalizeFcmToken, validateFcmKeyFile, type FcmConfig, type FcmRequest, type FcmResponse, type FcmTransport, } from '../src/push/fcm.js' // ── mocks: web-push (coexistence capture) + google-auth-library (no network) ── // `fcmClientRef.current`, when set by a test, replaces the OAuth2Client returned by // GoogleAuth.getClient() so a test can exercise the REAL createGoogleFcmTransport // (401 refresh-and-retry / thrown-error classification) via createFcmService. type FakeAuthClient = { request(opts: Record): Promise<{ status: number; data: string }> } const { sentWebPushPayloads, fcmSentRequests, fcmClientRef } = vi.hoisted(() => ({ sentWebPushPayloads: [] as string[], fcmSentRequests: [] as { url: string; body: string }[], fcmClientRef: { current: null as null | { request(opts: Record): Promise<{ status: number; data: string }> } }, })) vi.mock('web-push', () => ({ default: { setVapidDetails: (): void => {}, sendNotification: async (_sub: unknown, payload: string): Promise => { sentWebPushPayloads.push(payload) }, }, })) vi.mock('google-auth-library', () => ({ GoogleAuth: class { constructor(_opts: unknown) {} async getClient(): Promise<{ request(opts: Record): Promise<{ status: number; data: string }> }> { if (fcmClientRef.current !== null) return fcmClientRef.current return { async request(opts: Record): Promise<{ status: number; data: string }> { fcmSentRequests.push({ url: opts['url'] as string, body: opts['data'] as string }) return { status: 200, data: '' } }, } } }, })) const PTY_AVAILABLE = (() => { try { const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) p.kill() return true } catch { return false } })() const itPty = PTY_AVAILABLE ? it : it.skip // ── fixtures ─────────────────────────────────────────────────────────────────── const BASE_CFG: Config = { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 10_000, scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 300_000, reapIntervalMs: 60_000, previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], projectRoots: ['/home/tester'], projectScanDepth: 4, projectScanTtlMs: 10_000, projectDirtyCheck: true, editorCmd: 'code', vapidPublicKey: 'PUBKEY', vapidPrivateKey: 'PRIVKEY', vapidSubject: 'mailto:admin@example.com', pushStorePath: '/tmp/push-subs.json', pushMaxSubs: 50, notifyDone: true, notifyDnd: false, decisionTokenTtlMs: 300_000, timelineMax: 200, timelineEnabled: true, stuckTtlMs: 600_000, stuckAlert: true, diffTimeoutMs: 2000, diffMaxBytes: 2 * 1024 * 1024, diffMaxFiles: 300, statuslineTtlMs: 30_000, worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, defaultPermissionMode: 'default', allowAutoMode: false, } function cfg(overrides: Partial = {}): Config { return { ...BASE_CFG, ...overrides } } const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' const SECRET_CWD = '/home/tester/super-secret-project' function fakeSession(id = SID, cwd: string | null = SECRET_CWD): Session { return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session } const TOK_A = 'fcm-token-AAA:aaaaaaaaaaaa' const TOK_B = 'fcm-token-BBB:bbbbbbbbbbbb' const tmpFiles: string[] = [] function tmpPath(name: string): string { const p = path.join(os.tmpdir(), `webterm-fcm-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`) tmpFiles.push(p) return p } const FCM_ENV_OK = { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: '/tmp/sa-demo.json', } /** A well-formed (fake) service-account JSON — the private_key marker must never * appear in any log line. google-auth-library is mocked, so the key is inert. */ function serviceAccountKey(): { json: string; privateMarker: string } { const privateMarker = 'SUPER-SECRET-PRIVATE-KEY-MATERIAL' const json = JSON.stringify({ type: 'service_account', project_id: 'demo-project', private_key: `-----BEGIN PRIVATE KEY-----\n${privateMarker}\n-----END PRIVATE KEY-----\n`, client_email: 'fcm@demo-project.iam.gserviceaccount.com', }) return { json, privateMarker } } async function writeServiceAccountKey(): Promise<{ keyPath: string; privateMarker: string }> { const { json, privateMarker } = serviceAccountKey() const keyPath = tmpPath('sa.json') await fs.writeFile(keyPath, json) return { keyPath, privateMarker } } function fcmCfgFixture(overrides: Partial = {}): FcmConfig { return { projectId: 'demo-project', keyPath: '/unused/sa.json', storePath: tmpPath('svc-store'), host: 'https://fcm.googleapis.com', ...overrides, } } interface FakeTransport extends FcmTransport { requests: FcmRequest[] } /** Fake transport seam: records requests; per-call responder decides the outcome. */ function makeTransport( respond: (req: FcmRequest, callIndex: number) => FcmResponse | Promise = () => ({ status: 200, body: '' }), ): FakeTransport { const requests: FcmRequest[] = [] return { requests, async send(req: FcmRequest): Promise { requests.push(req) return respond(req, requests.length - 1) }, } } function makeService(options: { respond?: (req: FcmRequest, i: number) => FcmResponse | Promise cfgOverrides?: Partial fcmOverrides?: Partial tokens?: string[] }): { service: NotifyService; transport: FakeTransport; store: ReturnType } { const store = loadFcmTokenStore(tmpPath('unit-tokens.json'), 50) for (const t of options.tokens ?? [TOK_A]) store.add({ token: t, createdAt: 1 }) const transport = makeTransport(options.respond) const service = createFcmService(cfg(options.cfgOverrides), fcmCfgFixture(options.fcmOverrides), store, { transport }) return { service, transport, store } } afterEach(async () => { vi.restoreAllMocks() sentWebPushPayloads.length = 0 fcmSentRequests.length = 0 fcmClientRef.current = null for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: loadFcmConfig // ═══════════════════════════════════════════════════════════════════════════════ describe('loadFcmConfig', () => { it('returns ok with defaults when the critical pair is set', () => { const r = loadFcmConfig(FCM_ENV_OK) expect(r.ok).toBe(true) if (!r.ok) return expect(r.config.projectId).toBe('demo-project') expect(r.config.keyPath).toBe('/tmp/sa-demo.json') expect(r.config.storePath.endsWith('.web-terminal-fcm-tokens.json')).toBe(true) expect(r.config.host).toBe('https://fcm.googleapis.com') }) it('honors FCM_STORE_PATH / FCM_HOST overrides', () => { const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_STORE_PATH: '/tmp/custom-fcm.json', FCM_HOST: 'http://127.0.0.1:9', }) expect(r.ok).toBe(true) if (!r.ok) return expect(r.config.storePath).toBe('/tmp/custom-fcm.json') expect(r.config.host).toBe('http://127.0.0.1:9') }) it.each(['FCM_PROJECT_ID', 'FCM_KEY_PATH'])( 'disables the whole group when %s is missing (reason names the var)', (missing) => { const env: Record = { ...FCM_ENV_OK } delete env[missing] const r = loadFcmConfig(env) expect(r.ok).toBe(false) if (r.ok) return expect(r.reason).toContain(missing) }, ) it('treats an empty-string critical var as missing', () => { const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_PROJECT_ID: '' }) expect(r.ok).toBe(false) }) it('rejects a malformed FCM_HOST instead of crashing', () => { const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_HOST: 'not a url' }) expect(r.ok).toBe(false) }) it('rejects a non-http(s) FCM_HOST scheme', () => { const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_HOST: 'ftp://fcm.googleapis.com' }) expect(r.ok).toBe(false) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: normalizeFcmToken (loose, deliberate — §4.5) // ═══════════════════════════════════════════════════════════════════════════════ describe('normalizeFcmToken', () => { it('accepts a realistic base64url+colon token verbatim (case preserved)', () => { const raw = 'cXyZ-09_ABC:dEfGhIjK123' expect(normalizeFcmToken(raw)).toBe(raw) }) it('accepts the maximum bounded length', () => { const raw = 'a'.repeat(4096) expect(normalizeFcmToken(raw)).toBe(raw) }) it.each([ ['empty', ''], ['oversized (>4096)', 'a'.repeat(4097)], ['plus (non-base64url)', `${'a'.repeat(20)}+`], ['slash (non-base64url)', `${'a'.repeat(20)}/`], ['whitespace', `${'a'.repeat(20)} `], ['dot', `${'a'.repeat(20)}.`], ])('rejects %s', (_label, raw) => { expect(normalizeFcmToken(raw)).toBeNull() }) it.each([ ['number', 42], ['null', null], ['undefined', undefined], ['object', { token: TOK_A }], ])('rejects non-string input (%s)', (_label, raw) => { expect(normalizeFcmToken(raw)).toBeNull() }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: FcmTokenStore // ═══════════════════════════════════════════════════════════════════════════════ describe('loadFcmTokenStore', () => { it('starts empty when the file does not exist', () => { const store = loadFcmTokenStore(tmpPath('missing.json'), 50) expect(store.list()).toEqual([]) }) it('upserts idempotently by token (same token twice → one record)', () => { const store = loadFcmTokenStore(tmpPath('upsert.json'), 50) store.add({ token: TOK_A, createdAt: 1 }) store.add({ token: TOK_A, createdAt: 2 }) expect(store.list().length).toBe(1) expect(store.list()[0]?.createdAt).toBe(2) }) it('remove is idempotent (unknown token is a no-op)', () => { const store = loadFcmTokenStore(tmpPath('remove.json'), 50) store.add({ token: TOK_A, createdAt: 1 }) store.remove(TOK_B) expect(store.list().length).toBe(1) store.remove(TOK_A) store.remove(TOK_A) expect(store.list()).toEqual([]) }) it('FIFO-caps at maxTokens (oldest evicted)', () => { const store = loadFcmTokenStore(tmpPath('cap.json'), 2) store.add({ token: 'aaa', createdAt: 1 }) store.add({ token: 'bbb', createdAt: 2 }) store.add({ token: 'ccc', createdAt: 3 }) expect(store.list().map((r) => r.token)).toEqual(['bbb', 'ccc']) }) it('throws on a structurally invalid record', () => { const store = loadFcmTokenStore(tmpPath('invalid.json'), 50) expect(() => store.add({ token: 'bad token!', createdAt: 1 })).toThrow(TypeError) }) it('persists with 0600 permissions and round-trips through load', async () => { const file = tmpPath('persist.json') const store = loadFcmTokenStore(file, 50) store.add({ token: TOK_A, createdAt: 7 }) await store.persist() const st = await fs.stat(file) expect(st.mode & 0o777).toBe(0o600) const reloaded = loadFcmTokenStore(file, 50) expect(reloaded.list()).toEqual([{ token: TOK_A, createdAt: 7 }]) }) it('tolerates a malformed store file (starts empty, no throw)', async () => { const file = tmpPath('malformed.json') await fs.writeFile(file, 'not json at all') const store = loadFcmTokenStore(file, 50) expect(store.list()).toEqual([]) }) it('filters invalid entries out of a persisted file', async () => { const file = tmpPath('mixed.json') await fs.writeFile( file, JSON.stringify([{ token: TOK_A, createdAt: 1 }, { token: 'bad token!', createdAt: 1 }, 42]), ) const store = loadFcmTokenStore(file, 50) expect(store.list()).toEqual([{ token: TOK_A, createdAt: 1 }]) }) it('prune removes a batch of dead tokens', () => { const store = loadFcmTokenStore(tmpPath('prune.json'), 50) store.add({ token: TOK_A, createdAt: 1 }) store.add({ token: TOK_B, createdAt: 2 }) store.prune([TOK_A]) expect(store.list().map((r) => r.token)).toEqual([TOK_B]) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: validateFcmKeyFile (never logs key material) // ═══════════════════════════════════════════════════════════════════════════════ describe('validateFcmKeyFile', () => { it('accepts a well-formed service-account JSON', async () => { const { keyPath } = await writeServiceAccountKey() expect(validateFcmKeyFile(keyPath)).toBe(true) }) it('returns false (never throws) for a missing file', () => { expect(validateFcmKeyFile('/nonexistent/sa.json')).toBe(false) }) it('returns false for non-JSON, never echoing the contents', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const file = tmpPath('garbage.json') await fs.writeFile(file, 'MY-LEAKED-SECRET not json') expect(validateFcmKeyFile(file)).toBe(false) for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain('MY-LEAKED-SECRET') } }) it('returns false when client_email / private_key are missing', async () => { const file = tmpPath('shape.json') await fs.writeFile(file, JSON.stringify({ type: 'service_account', project_id: 'x' })) expect(validateFcmKeyFile(file)).toBe(false) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: createFcmService — DATA-ONLY payload shapes + minimization // ═══════════════════════════════════════════════════════════════════════════════ function parseMessage(req: FcmRequest): { token: unknown; data: Record; android: Record; hasNotification: boolean } { const parsed = JSON.parse(req.body) as { message: Record } const message = parsed.message return { token: message['token'], data: message['data'] as Record, android: message['android'] as Record, hasNotification: 'notification' in message, } } describe('createFcmService payload shape', () => { it('NEEDS-INPUT: HIGH priority, data-only {sessionId, cls, token}, NO notification block', async () => { const { service, transport } = makeService({}) await service.notify(fakeSession(), 'needs-input', 'cap-token-1') expect(transport.requests.length).toBe(1) const req = transport.requests[0] as FcmRequest expect(req.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send') const m = parseMessage(req) expect(m.token).toBe(TOK_A) expect(m.hasNotification).toBe(false) expect(m.android['priority']).toBe('HIGH') expect(m.android['ttl']).toBe('300s') // decisionTokenTtlMs 300_000 → 300s expect(m.data).toEqual({ sessionId: SID, cls: 'needs-input', token: 'cap-token-1' }) }) it('DONE: NORMAL priority, no decision token, still data-only', async () => { const { service, transport } = makeService({}) await service.notify(fakeSession(), 'done') const req = transport.requests[0] as FcmRequest const m = parseMessage(req) expect(m.hasNotification).toBe(false) expect(m.android['priority']).toBe('NORMAL') expect(m.android['ttl']).toBe('600s') expect(m.data).toEqual({ sessionId: SID, cls: 'done' }) expect('token' in m.data).toBe(false) }) it('NEVER leaks cwd, command content, or terminal output into the payload', async () => { const { service, transport } = makeService({}) await service.notify(fakeSession(SID, SECRET_CWD), 'needs-input', 'tok') await service.notify(fakeSession(SID, SECRET_CWD), 'done') for (const req of transport.requests) { expect(req.body).not.toContain('super-secret-project') expect(req.body).not.toContain(SECRET_CWD) expect(req.body).not.toContain('cwd') expect(req.body).not.toContain('command') expect(req.body).not.toContain('notification') } }) it('P1 signal list is NEEDS-INPUT + DONE only: stuck sends nothing over FCM', async () => { const { service, transport } = makeService({}) await service.notify(fakeSession(), 'stuck') expect(transport.requests.length).toBe(0) }) it('fans out to every registered token', async () => { const { service, transport } = makeService({ tokens: [TOK_A, TOK_B] }) await service.notify(fakeSession(), 'done') expect(transport.requests.map((r) => parseMessage(r).token).sort()).toEqual([TOK_A, TOK_B].sort()) }) it('sends nothing when no tokens are registered', async () => { const { service, transport } = makeService({ tokens: [] }) await service.notify(fakeSession(), 'needs-input', 'tok') expect(transport.requests.length).toBe(0) }) it('honors NOTIFY_DND (mirrors web-push / apns semantics)', async () => { const { service, transport } = makeService({ cfgOverrides: { notifyDnd: true } }) await service.notify(fakeSession(), 'needs-input', 'tok') expect(transport.requests.length).toBe(0) }) it('honors NOTIFY_DONE=false for done but still sends needs-input', async () => { const { service, transport } = makeService({ cfgOverrides: { notifyDone: false } }) await service.notify(fakeSession(), 'done') expect(transport.requests.length).toBe(0) await service.notify(fakeSession(), 'needs-input', 'tok') expect(transport.requests.length).toBe(1) }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: response handling — prune on dead tokens, resilience, no secret logging // ═══════════════════════════════════════════════════════════════════════════════ const UNREGISTERED_BODY = JSON.stringify({ error: { code: 404, status: 'NOT_FOUND', details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'UNREGISTERED' }], }, }) const INVALID_ARG_BODY = JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'INVALID_ARGUMENT' }], }, }) describe('createFcmService response handling', () => { it('evicts a token on UNREGISTERED and keeps healthy ones', async () => { const { service, store } = makeService({ tokens: [TOK_A, TOK_B], respond: (req) => req.body.includes(TOK_A) ? { status: 404, body: UNREGISTERED_BODY } : { status: 200, body: '' }, }) await service.notify(fakeSession(), 'done') expect(store.list().map((r) => r.token)).toEqual([TOK_B]) }) it('evicts a token on INVALID_ARGUMENT', async () => { const { service, store } = makeService({ respond: () => ({ status: 400, body: INVALID_ARG_BODY }), }) await service.notify(fakeSession(), 'done') expect(store.list()).toEqual([]) }) it('keeps the token on a 401 (lib auto-refreshes) and on 429 (transient)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { service, store } = makeService({ respond: (_req, i) => i === 0 ? { status: 401, body: '{"error":{"status":"UNAUTHENTICATED"}}' } : { status: 429, body: '{"error":{"status":"RESOURCE_EXHAUSTED"}}' }, }) await service.notify(fakeSession(), 'done') await service.notify(fakeSession(), 'done') expect(store.list().length).toBe(1) expect(errSpy).toHaveBeenCalled() for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain(TOK_A) } }) it('never throws on transport errors and keeps the token', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { service, store } = makeService({ respond: () => { throw new Error('ECONNREFUSED fcm.googleapis.com:443') }, }) await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() expect(store.list().length).toBe(1) expect(errSpy).toHaveBeenCalled() for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain(TOK_A) } }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: default google-auth-library transport — 401 refresh-and-retry + thrown- // error classification (regression: validateStatus:()=>true defeated the refresh) // ═══════════════════════════════════════════════════════════════════════════════ /** Build a service that uses the REAL createGoogleFcmTransport (no injected * transport) so GoogleAuth.getClient() → fcmClientRef.current is exercised. */ function makeServiceWithDefaultTransport(tokens: string[] = [TOK_A]): { service: NotifyService store: ReturnType } { const store = loadFcmTokenStore(tmpPath('default-transport.json'), 50) for (const t of tokens) store.add({ token: t, createdAt: 1 }) const service = createFcmService(cfg(), fcmCfgFixture(), store) // no deps → createGoogleFcmTransport return { service, store } } /** * Models google-auth-library's OAuth2Client.request(): its internal gaxios call * THROWS on a >=400 status, and that throw is what makes the client refresh the * access token and retry a 401 once. A stale token 401s; after the refresh the * token is fresh and 200s. If the caller passes validateStatus that swallows the * 401 (the bug), gaxios never throws → the refresh path never runs. */ function refreshOn401Client(): { client: FakeAuthClient; state: { gaxiosCalls: number; refreshes: number } } { const state = { gaxiosCalls: 0, refreshes: 0 } let fresh = false const request = async (opts: Record): Promise<{ status: number; data: string }> => { state.gaxiosCalls++ const res = fresh ? { status: 200, data: '' } : { status: 401, data: '{"error":{"status":"UNAUTHENTICATED"}}' } if (res.status < 400) return res const validateStatus = opts['validateStatus'] as ((s: number) => boolean) | undefined if (validateStatus?.(res.status) === true) return res // 401 swallowed → no refresh (the bug) state.refreshes++ fresh = true return request(opts) // OAuth2Client refreshes the token + retries once } return { client: { request }, state } } /** A client whose request always throws — with `response` set it mimics a thrown * GaxiosError (HTTP error); with null it mimics a real transport error (no HTTP). */ function throwingClient(response: { status: number; data: string } | null): FakeAuthClient { return { async request(): Promise<{ status: number; data: string }> { const err = new Error('ECONNREFUSED fcm.googleapis.com:443') as Error & { response?: unknown } if (response !== null) err.response = response throw err }, } } describe('default google-auth-library transport (createGoogleFcmTransport)', () => { it('a 401 refreshes-and-retries and the send ultimately succeeds (refresh path exercised)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { client, state } = refreshOn401Client() fcmClientRef.current = client const { service, store } = makeServiceWithDefaultTransport() await service.notify(fakeSession(), 'done') // gaxios ran twice (initial 401 + post-refresh 200) and the client refreshed once. expect(state.gaxiosCalls).toBe(2) expect(state.refreshes).toBe(1) // Success ⇒ token kept and no "send failed" line logged (buggy code returns the // 401 verbatim → refreshes 0, gaxiosCalls 1, and a send-failure is logged). expect(store.list().map((r) => r.token)).toEqual([TOK_A]) for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain('send failed') } }) it('classifies a thrown 404/UNREGISTERED GaxiosError as a dead token → prunes it', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) fcmClientRef.current = throwingClient({ status: 404, data: UNREGISTERED_BODY }) const { service, store } = makeServiceWithDefaultTransport() await service.notify(fakeSession(), 'done') expect(store.list()).toEqual([]) // thrown 404 must still evict the dead token }) it('treats a thrown error with NO response as a send failure → keeps the token', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) fcmClientRef.current = throwingClient(null) const { service, store } = makeServiceWithDefaultTransport() await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() expect(store.list().map((r) => r.token)).toEqual([TOK_A]) // never a false dead-token prune expect(errSpy).toHaveBeenCalled() const logged = errSpy.mock.calls.map((c) => c.join(' ')).join('\n') expect(logged).toContain('send failed') expect(logged).not.toContain(TOK_A) // token still never logged }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Unit: initFcm — disabled semantics (startup never crashes, one log line) // ═══════════════════════════════════════════════════════════════════════════════ describe('initFcm', () => { it('returns null with one log line when nothing is configured', () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(initFcm(cfg(), {})).toBeNull() const fcmLines = errSpy.mock.calls.filter((c) => String(c[0]).includes('fcm')) expect(fcmLines.length).toBe(1) }) it('returns null when the group is only partially configured', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) expect(initFcm(cfg(), { FCM_PROJECT_ID: 'demo-project' })).toBeNull() }) it('returns null (no crash) when the key file is unreadable or garbage', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const file = tmpPath('bad.json') await fs.writeFile(file, 'garbage') expect(initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: file, FCM_STORE_PATH: tmpPath('s1.json') })).toBeNull() }) it('returns a runtime with a working store when fully configured — never logging key material', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { keyPath, privateMarker } = await writeServiceAccountKey() const rt = initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: keyPath, FCM_STORE_PATH: tmpPath('init-store.json') }) expect(rt).not.toBeNull() if (rt === null) return expect(rt.service.isEnabled()).toBe(true) rt.store.add({ token: TOK_A, createdAt: 1 }) expect(rt.store.list().length).toBe(1) for (const call of errSpy.mock.calls) { expect(call.join(' ')).not.toContain(privateMarker) expect(call.join(' ')).not.toContain('PRIVATE KEY') } }) }) // ═══════════════════════════════════════════════════════════════════════════════ // Integration: real startServer on an ephemeral port // ═══════════════════════════════════════════════════════════════════════════════ function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (addr === null || typeof addr === 'string') { srv.close() reject(new Error('bad addr')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } const handles: { close(): Promise }[] = [] const FCM_ENV_KEYS = ['FCM_PROJECT_ID', 'FCM_KEY_PATH', 'FCM_STORE_PATH', 'FCM_HOST'] as const /** Run startServer with FCM_* temporarily injected into process.env, restoring * the previous values right after (the server reads the group once at startup). */ async function spawnServer(options: { fcmEnv?: Partial> cfgEnv?: Record } = {}): Promise<{ port: number; origin: string; fcmStorePath: string }> { const port = await getFreePort() const pushStorePath = tmpPath(`web-subs-${port}.json`) const fcmStorePath = options.fcmEnv?.FCM_STORE_PATH ?? tmpPath(`fcm-tokens-${port}.json`) const saved = new Map() for (const k of FCM_ENV_KEYS) saved.set(k, process.env[k]) for (const k of FCM_ENV_KEYS) delete process.env[k] if (options.fcmEnv !== undefined) { for (const [k, v] of Object.entries(options.fcmEnv)) process.env[k] = v if (options.fcmEnv.FCM_STORE_PATH === undefined) process.env['FCM_STORE_PATH'] = fcmStorePath } try { const config = loadConfig({ PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, USE_TMUX: '0', IDLE_TTL: '86400', PUSH_STORE_PATH: pushStorePath, ...options.cfgEnv, }) handles.push(startServer(config)) } finally { for (const [k, v] of saved) { if (v === undefined) delete process.env[k] else process.env[k] = v } } await new Promise((r) => setTimeout(r, 80)) return { port, origin: `http://127.0.0.1:${port}`, fcmStorePath } } async function makeFcmEnv(): Promise> { const { keyPath } = await writeServiceAccountKey() return { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: keyPath } } function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise { return fetch(`http://127.0.0.1:${port}/push/fcm-token`, { method, headers: { 'Content-Type': 'application/json', ...(origin !== undefined ? { Origin: origin } : {}) }, body: JSON.stringify({ token }), }) } afterEach(async () => { while (handles.length > 0) await handles.pop()?.close() await new Promise((r) => setTimeout(r, 30)) }) describe('disabled mode (no FCM_* env)', () => { it('does not mount the token routes and the server still works', async () => { const { port, origin } = await spawnServer() expect((await postToken(port, TOK_A, origin)).status).toBe(404) expect((await postToken(port, TOK_A, origin, 'DELETE')).status).toBe(404) const alive = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(alive.status).toBe(200) }) it('stays disabled (no crash) when the group is partial or the key is garbage', async () => { const badKey = tmpPath('garbage-server.json') await fs.writeFile(badKey, 'garbage') const { port, origin } = await spawnServer({ fcmEnv: { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: badKey }, }) expect((await postToken(port, TOK_A, origin)).status).toBe(404) expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200) }) }) describe('POST/DELETE /push/fcm-token (frozen wire shape)', () => { it('rejects foreign and missing Origin with 403', async () => { const { port } = await spawnServer({ fcmEnv: await makeFcmEnv() }) expect((await postToken(port, TOK_A, 'http://evil.example')).status).toBe(403) expect((await postToken(port, TOK_A)).status).toBe(403) expect((await postToken(port, TOK_A, 'http://evil.example', 'DELETE')).status).toBe(403) }) it('rejects invalid tokens with 400', async () => { const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv() }) expect((await postToken(port, '', origin)).status).toBe(400) expect((await postToken(port, 'has spaces here', origin)).status).toBe(400) expect((await postToken(port, 42, origin)).status).toBe(400) }) it('registers idempotently (204 twice), preserving token case', async () => { const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() }) const token = 'AbC-123_xyz:MixedCase' expect((await postToken(port, token, origin)).status).toBe(204) expect((await postToken(port, token, origin)).status).toBe(204) const stored = JSON.parse(await fs.readFile(fcmStorePath, 'utf8')) as { token: string }[] expect(stored.length).toBe(1) expect(stored[0]?.token).toBe(token) }) it('unregisters idempotently (204 even for an unknown token)', async () => { const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() }) expect((await postToken(port, TOK_A, origin)).status).toBe(204) expect((await postToken(port, TOK_A, origin, 'DELETE')).status).toBe(204) expect((await postToken(port, TOK_B, origin, 'DELETE')).status).toBe(204) const stored = JSON.parse(await fs.readFile(fcmStorePath, 'utf8')) as { token: string }[] expect(stored.length).toBe(0) }) it('rate-limits more than 5 calls/min from one IP (429)', async () => { const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv() }) const codes: number[] = [] for (let i = 0; i < 6; i++) { codes.push((await postToken(port, `tok${i}:${'a'.repeat(40)}`, origin)).status) } expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) expect(codes[5]).toBe(429) }) }) // ── coexistence: held gate → BOTH web-push and FCM fire with the same token ── function waitForOpen(ws: WebSocket): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('open timeout')), 3000) ws.once('open', () => { clearTimeout(t) resolve() }) ws.once('error', (e) => { clearTimeout(t) reject(e) }) }) } async function createDetachedSession(port: number, origin: string): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const sessionId = await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('attach timeout')), 5000) ws.on('message', (raw) => { try { const m = JSON.parse(String(raw)) as Record if (m['type'] === 'attached') { clearTimeout(t) resolve(m['sessionId'] as string) } } catch { /* ignore non-JSON frames */ } }) }) ws.close() await new Promise((r) => setTimeout(r, 150)) return sessionId } describe('coexistence: web-push + FCM on a held gate', () => { itPty('fires BOTH paths with the same capability token; the FCM payload is data-only', async () => { const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv(), cfgEnv: { VAPID_PUBLIC_KEY: 'test-public-key', VAPID_PRIVATE_KEY: 'test-private-key', VAPID_SUBJECT: 'mailto:test@localhost', PERM_TIMEOUT_MS: '4000', }, }) // Register a web-push subscription AND an FCM device token. const sub = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ endpoint: 'https://push.example/ep-1', keys: { p256dh: 'p', auth: 'a' } }), }) expect(sub.status).toBe(204) expect((await postToken(port, TOK_A, origin)).status).toBe(204) const sessionId = await createDetachedSession(port, origin) // Fire the held permission hook (loopback); do NOT await — it must be HELD. sentWebPushPayloads.length = 0 fcmSentRequests.length = 0 let resolved = false const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }).then(async (r) => { resolved = true return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } }) await new Promise((r) => setTimeout(r, 400)) expect(resolved).toBe(false) // Web-push path: unchanged. expect(sentWebPushPayloads.length).toBe(1) const webToken = (JSON.parse(sentWebPushPayloads[0] as string) as { token?: string }).token expect(typeof webToken).toBe('string') // FCM path: same gate, same capability token, data-only frozen shape. expect(fcmSentRequests.length).toBe(1) const fcmReq = fcmSentRequests[0] as { url: string; body: string } expect(fcmReq.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send') const message = (JSON.parse(fcmReq.body) as { message: Record }).message expect(message['token']).toBe(TOK_A) expect('notification' in message).toBe(false) const data = message['data'] as Record expect(data['sessionId']).toBe(sessionId) expect(data['cls']).toBe('needs-input') expect(data['token']).toBe(webToken) expect((message['android'] as Record)['priority']).toBe('HIGH') expect(fcmReq.body).not.toContain('"cwd"') // The capability token semantics are UNCHANGED: a single decision resolves it. const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId, decision: 'allow', token: webToken }), }) expect(dec.status).toBe(204) const decision = await permPromise expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') }) itPty('an FCM token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => { const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv(), cfgEnv: { PERM_TIMEOUT_MS: '4000' }, }) expect((await postToken(port, TOK_A, origin)).status).toBe(204) const sessionId = await createDetachedSession(port, origin) fcmSentRequests.length = 0 let resolved = false void fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }).then(() => { resolved = true }) await new Promise((r) => setTimeout(r, 400)) expect(resolved).toBe(false) // held — not fallen through to Claude's own prompt expect(fcmSentRequests.length).toBe(1) // Release it so the server can shut down cleanly. const message = (JSON.parse((fcmSentRequests[0] as { body: string }).body) as { message: Record }).message const capToken = (message['data'] as Record)['token'] const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify({ sessionId, decision: 'deny', token: capToken }), }) expect(dec.status).toBe(204) }) })