Mirror src/push/apns.ts behind the same NotifyService seam (combineNotifyServices), so held-gate/done hook events fan out to Android via data-only high-priority FCM with zero new event paths. OAuth2 bearer via google-auth-library (R7); payload minimized to sessionId/cls/token (never cwd/command/bytes); intentionally-loose FCM-token validator; Origin-guarded + rate-limited POST/DELETE /push/fcm-token; FCM_* env all-or-disabled. Cross-review fix: dropped validateStatus:()=>true so google-auth-library's built-in 401 refresh-and-retry actually fires. 60 new tests; full server suite green; tsc clean.
1026 lines
43 KiB
TypeScript
1026 lines
43 KiB
TypeScript
/**
|
|
* 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<string, unknown>): 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<string, unknown>): Promise<{ status: number; data: string }> } },
|
|
}))
|
|
vi.mock('web-push', () => ({
|
|
default: {
|
|
setVapidDetails: (): void => {},
|
|
sendNotification: async (_sub: unknown, payload: string): Promise<void> => {
|
|
sentWebPushPayloads.push(payload)
|
|
},
|
|
},
|
|
}))
|
|
vi.mock('google-auth-library', () => ({
|
|
GoogleAuth: class {
|
|
constructor(_opts: unknown) {}
|
|
async getClient(): Promise<{ request(opts: Record<string, unknown>): Promise<{ status: number; data: string }> }> {
|
|
if (fcmClientRef.current !== null) return fcmClientRef.current
|
|
return {
|
|
async request(opts: Record<string, unknown>): 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> = {}): 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> = {}): 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<FcmResponse> = () => ({ status: 200, body: '' }),
|
|
): FakeTransport {
|
|
const requests: FcmRequest[] = []
|
|
return {
|
|
requests,
|
|
async send(req: FcmRequest): Promise<FcmResponse> {
|
|
requests.push(req)
|
|
return respond(req, requests.length - 1)
|
|
},
|
|
}
|
|
}
|
|
|
|
function makeService(options: {
|
|
respond?: (req: FcmRequest, i: number) => FcmResponse | Promise<FcmResponse>
|
|
cfgOverrides?: Partial<Config>
|
|
fcmOverrides?: Partial<FcmConfig>
|
|
tokens?: string[]
|
|
}): { service: NotifyService; transport: FakeTransport; store: ReturnType<typeof loadFcmTokenStore> } {
|
|
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<string, string | undefined> = { ...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<string, unknown>; android: Record<string, unknown>; hasNotification: boolean } {
|
|
const parsed = JSON.parse(req.body) as { message: Record<string, unknown> }
|
|
const message = parsed.message
|
|
return {
|
|
token: message['token'],
|
|
data: message['data'] as Record<string, unknown>,
|
|
android: message['android'] as Record<string, unknown>,
|
|
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<typeof loadFcmTokenStore>
|
|
} {
|
|
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<string, unknown>): 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<number> {
|
|
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<void> }[] = []
|
|
|
|
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<Record<(typeof FCM_ENV_KEYS)[number], string>>
|
|
cfgEnv?: Record<string, string | undefined>
|
|
} = {}): 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<string, string | undefined>()
|
|
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<void>((r) => setTimeout(r, 80))
|
|
return { port, origin: `http://127.0.0.1:${port}`, fcmStorePath }
|
|
}
|
|
|
|
async function makeFcmEnv(): Promise<Record<string, string>> {
|
|
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<Response> {
|
|
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<void>((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<void> {
|
|
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<string> {
|
|
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<string>((resolve, reject) => {
|
|
const t = setTimeout(() => reject(new Error('attach timeout')), 5000)
|
|
ws.on('message', (raw) => {
|
|
try {
|
|
const m = JSON.parse(String(raw)) as Record<string, unknown>
|
|
if (m['type'] === 'attached') {
|
|
clearTimeout(t)
|
|
resolve(m['sessionId'] as string)
|
|
}
|
|
} catch {
|
|
/* ignore non-JSON frames */
|
|
}
|
|
})
|
|
})
|
|
ws.close()
|
|
await new Promise<void>((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<void>((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<string, unknown> }).message
|
|
expect(message['token']).toBe(TOK_A)
|
|
expect('notification' in message).toBe(false)
|
|
const data = message['data'] as Record<string, unknown>
|
|
expect(data['sessionId']).toBe(sessionId)
|
|
expect(data['cls']).toBe('needs-input')
|
|
expect(data['token']).toBe(webToken)
|
|
expect((message['android'] as Record<string, unknown>)['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<void>((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<string, unknown> }).message
|
|
const capToken = (message['data'] as Record<string, unknown>)['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)
|
|
})
|
|
})
|