import { describe, test, expect } from 'bun:test'; import { generateKeyPairSync, createVerify } from 'node:crypto'; import { FcmAccessToken, parseServiceAccount } from './fcm-auth'; import { __testing } from './fcm'; import type { Notification } from './types'; import type { Catalogue } from '../../notifications/catalogue'; // What can be proven without Google: the assertion is a well-formed RS256 JWT with the claims the token // endpoint requires, and the message body matches FCM v1's shape — including the string-only `data` rule // that is the usual reason a first integration fails. const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const account = { project_id: 'officer-test', client_email: 'push@officer-test.iam.gserviceaccount.com', private_key: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, }; const decode = (part: string) => JSON.parse(Buffer.from(part, 'base64url').toString()); describe('FCM service account', () => { test('rejects an incomplete account rather than failing at send time', () => { expect(() => parseServiceAccount('{"project_id":"x"}')).toThrow(/missing/); }); test('accepts a key whose newlines were escaped, as they are in the JSON', () => { const escaped = { ...account, private_key: account.private_key.replace(/\n/g, '\\n') }; expect(() => new FcmAccessToken(escaped).buildAssertion()).not.toThrow(); }); }); describe('OAuth2 assertion', () => { const auth = new FcmAccessToken(account); test('carries the claims Google requires', () => { const [h, c] = auth.buildAssertion().split('.'); expect(decode(h!)).toEqual({ alg: 'RS256', typ: 'JWT' }); const claims = decode(c!); expect(claims.iss).toBe(account.client_email); expect(claims.aud).toBe('https://oauth2.googleapis.com/token'); expect(claims.scope).toBe('https://www.googleapis.com/auth/firebase.messaging'); expect(claims.exp - claims.iat).toBe(3600); }); test('the signature verifies over header.claims', () => { const [h, c, s] = auth.buildAssertion().split('.'); const v = createVerify('RSA-SHA256'); v.update(`${h}.${c}`); expect(v.verify(publicKey, Buffer.from(s!, 'base64url'))).toBe(true); }); }); describe('FCM message', () => { const build = __testing.buildMessage; const catalogue: Catalogue = { version: 77, entries: { 'officer/core:mail': { title: '{n} new emails' } } }; const ctx = { catalogue }; // The rule that breaks first integrations: FCM rejects a non-string in `data` with a bare // INVALID_ARGUMENT that says nothing about which field. test('every data value is a string, including numbers', () => { const msg = build('tok', { code: 'officer/core:job', userId: 1, id: 412, n: 2 }, ctx); for (const [key, value] of Object.entries(msg.message.data)) { expect(typeof value, `data.${key}`).toBe('string'); } expect(msg.message.data).toEqual({ c: 'officer/core:job', mv: '77', id: '412', n: '2' }); }); test('omits keys that were not provided, rather than sending "undefined"', () => { const msg = build('tok', { code: 'officer/core:mail', userId: 1, n: 3 }, ctx); expect(msg.message.data).toEqual({ c: 'officer/core:mail', mv: '77', n: '3' }); expect(Object.keys(msg.message.data)).not.toContain('id'); }); // DATA-ONLY is the whole Android half of the design. A `notification` block would be rendered by the // system, putting the code on the lock screen with no chance for the app to resolve it. test('sends no notification block, so the app composes the banner itself', () => { const msg = build('device-token', { code: 'officer/core:mail', userId: 1, n: 3 }, ctx); expect(msg.message.token).toBe('device-token'); expect('notification' in msg.message).toBe(false); }); // Load-bearing: a normal-priority data message can be deferred indefinitely in Doze, and a // notification that arrives tomorrow is not a notification. test('is high priority', () => { expect(build('t', { code: 'officer/core:mail', userId: 1 }, ctx).message.android.priority).toBe('high'); }); test('the catalogue version travels, so the device knows when to resync', () => { const msg = build('t', { code: 'officer/core:mail', userId: 1 }, { catalogue: { ...catalogue, version: 5 } }); expect(msg.message.data.mv).toBe('5'); }); // The same payload guarantee asserted for APNs, on the other channel. test('a producer cannot smuggle content through', () => { const sneaky = { code: 'officer/core:mail', userId: 1, n: 1, subject: 'Re: your invoice', from: 'accounts@example.com', } as unknown as Notification; const json = JSON.stringify(build('tok', sneaky, ctx)); expect(json).not.toContain('invoice'); expect(json).not.toContain('example.com'); }); });