notify: the FCM channel
Step 4, and the last transport. Google directly via FCM HTTP v1 — no Expo, no firebase-admin. Unlike APNs the signed JWT is not the credential: it is an assertion exchanged at oauth2.googleapis.com for a 1-hour access token, so there are two things to cache and a network round trip on the cold path. Concurrent pushes share one in-flight exchange rather than each starting their own, and the token refreshes five minutes early so a request cannot race the expiry. The detail that breaks most first FCM integrations: `message.data` values must all be STRINGS. A number or boolean is rejected with a bare INVALID_ARGUMENT that does not say which field. Everything is stringified on the way out — id, ok and count included — and a test walks every value asserting its type rather than trusting the code that wrote it. Also v1-specific: there is no multicast (the /batch endpoint is deprecated), so N devices is N requests, which happens to match the APNs shape anyway. Errors are read for `error.status` only. FCM's `message` field can echo the device token, so logging the whole body would put device addresses in the logs. UNREGISTERED / INVALID_ARGUMENT / NOT_FOUND delete the row; anything else counts a strike. 21 tests across both channels, and verified against the real endpoint: a throwaway key gets 400 invalid_grant "account not found" from Google, meaning the endpoint, form encoding, grant_type, RS256 signature and claim structure were all accepted and only the account is missing. A malformed assertion would have failed earlier, with a different error. The doorbell rule is asserted on this channel too: a producer passing subject/from cannot get either into the serialized message. Still needed for a real send: FCM_SERVICE_ACCOUNT, a Firebase project, and google-services.json in the Android build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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';
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 and booleans', () => {
|
||||
const msg = build('tok', { type: 'job', userId: 1, id: 412, ok: false, count: 2 });
|
||||
for (const [key, value] of Object.entries(msg.message.data)) {
|
||||
expect(typeof value, `data.${key}`).toBe('string');
|
||||
}
|
||||
expect(msg.message.data).toEqual({ type: 'job', id: '412', ok: 'false', count: '2' });
|
||||
});
|
||||
|
||||
test('omits keys that were not provided, rather than sending "undefined"', () => {
|
||||
const msg = build('tok', { type: 'mail', userId: 1, count: 3 });
|
||||
expect(msg.message.data).toEqual({ type: 'mail', count: '3' });
|
||||
expect(Object.keys(msg.message.data)).not.toContain('id');
|
||||
});
|
||||
|
||||
test('title is the generic rendered one and the token is set', () => {
|
||||
const msg = build('device-token', { type: 'mail', userId: 1, count: 3 });
|
||||
expect(msg.message.token).toBe('device-token');
|
||||
expect(msg.message.notification.title).toBe('3 new emails');
|
||||
});
|
||||
|
||||
test('tags by category so banners group instead of stacking', () => {
|
||||
expect(build('t', { type: 'mail', userId: 1 }).message.android.notification.tag).toBe('mail');
|
||||
});
|
||||
|
||||
// The same doorbell guarantee asserted for APNs, on the other channel.
|
||||
test('a producer cannot smuggle content through', () => {
|
||||
const sneaky = {
|
||||
type: 'mail',
|
||||
userId: 1,
|
||||
count: 1,
|
||||
subject: 'Re: your invoice',
|
||||
from: 'accounts@example.com',
|
||||
} as unknown as Notification;
|
||||
|
||||
const json = JSON.stringify(build('tok', sneaky));
|
||||
expect(json).not.toContain('invoice');
|
||||
expect(json).not.toContain('example.com');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user