import * as NodeCrypto from "node:crypto"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { RelayConfiguration } from "../Config.ts"; import { FcmClient, layer } from "./FcmClient.ts"; import * as WebCrypto from "../WebCrypto.ts"; import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); const account = { project_id: "test-project", client_email: "push@test-project.iam.gserviceaccount.com", private_key: privateKey, }; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const config = { relayIssuer: "https://relay.test", apns: { environment: "sandbox", teamId: "team", keyId: "key", bundleId: "app", privateKey: Redacted.make("unused"), }, fcmServiceAccount: Redacted.make(encodeJson(account)), clerkSecretKey: Redacted.make("unused"), clerkPublishableKey: "unused", clerkJwtAudience: "unused", apnsDeliveryJobSigningSecret: Redacted.make("unused"), cloudMintPrivateKey: Redacted.make("unused"), cloudMintPublicKey: "unused", managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, } satisfies RelayConfiguration["Service"]; const input = { token: "device-token", packageName: "com.t3tools.t3code.dev", data: { t3_kind: "agent_activity", active: "true" }, alert: false, }; function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) { const http = HttpClient.make((request) => { requests.push(request); const response = responses.shift(); return response ? Effect.succeed(HttpClientResponse.fromWeb(request, response)) : Effect.die("unexpected request"); }); return layer.pipe( Layer.provide( FcmAssertionSigner.layer.pipe( Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), ), ), Layer.provide( Layer.mergeAll( Layer.succeed(RelayConfiguration, config), Layer.succeed(HttpClient.HttpClient, http), ), ), ); } describe("FCM delivery", () => { it.effect.each([ { operation: "authorize", stage: "headers", status: null }, { operation: "authorize", stage: "body", status: 200 }, { operation: "send", stage: "headers", status: null }, { operation: "send", stage: "body", status: 503 }, ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) => Effect.gen(function* () { const started = yield* Deferred.make(); let shouldStall = true; const http = HttpClient.make((request) => { const isAuthorization = request.url === "https://oauth2.googleapis.com/token"; const stall = shouldStall && isAuthorization === (scenario.operation === "authorize"); const response = HttpClientResponse.fromWeb( request, isAuthorization ? Response.json({ access_token: "access-token" }) : Response.json({}, { status: stall ? 503 : 200 }), ); if (stall) { shouldStall = false; const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); if (scenario.stage === "headers") return stalled; Object.defineProperty(response, "json", { value: stalled }); } return Effect.succeed(response); }); yield* Effect.gen(function* () { const client = yield* FcmClient; const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild); yield* Deferred.await(started); yield* TestClock.adjust("10 seconds"); expect(yield* Fiber.join(delivery)).toMatchObject({ _tag: "FcmClientError", operation: scenario.operation, status: scenario.status, cause: expect.objectContaining({ _tag: "TimeoutError" }), }); expect(yield* client.send(input)).toEqual({ unregistered: false }); }).pipe( Effect.provide( layer.pipe( Layer.provide( FcmAssertionSigner.layer.pipe( Layer.provide( Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), ), ), ), Layer.provide(Layer.succeed(RelayConfiguration, config)), Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), ), ), ); }), ); it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => Effect.gen(function* () { const signer = yield* FcmAssertionSigner.FcmAssertionSigner; const assertion = yield* signer.sign({ privateKey: account.private_key, clientEmail: account.client_email, issuedAt: 1000, }); const [header, claims, signature] = assertion.split("."); expect( NodeCrypto.verify( "RSA-SHA256", Buffer.from(`${header}.${claims}`), publicKey, Buffer.from(signature!, "base64url"), ), ).toBe(true); expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({ iss: account.client_email, scope: "https://www.googleapis.com/auth/firebase.messaging", aud: "https://oauth2.googleapis.com/token", iat: 1000, exp: 4600, }); }).pipe( Effect.provide( FcmAssertionSigner.layer.pipe( Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), ), ), ), ); it.effect( "reuses OAuth authorization and sends native data messages to the correct package", () => { const requests: HttpClientRequest.HttpClientRequest[] = []; return Effect.gen(function* () { const client = yield* FcmClient; yield* client.send(input); yield* client.send({ ...input, alert: true }); expect(requests.map((request) => request.url)).toEqual([ "https://oauth2.googleapis.com/token", "https://fcm.googleapis.com/v1/projects/test-project/messages:send", "https://fcm.googleapis.com/v1/projects/test-project/messages:send", ]); expect(requests[1]!.headers.authorization).toBe("Bearer access-token"); const body = requests[1]!.body; if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body"); expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({ message: { token: input.token, data: input.data, android: { priority: "HIGH", ttl: "300s", collapse_key: "t3-agent-activity", restricted_package_name: input.packageName, }, }, }); }).pipe( Effect.provide( testLayer(requests, [ Response.json({ access_token: "access-token" }), Response.json({ name: "one" }), Response.json({ name: "two" }), ]), ), ); }, ); it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => { const requests: HttpClientRequest.HttpClientRequest[] = []; return Effect.gen(function* () { const client = yield* FcmClient; const first = yield* client.send(input).pipe(Effect.flip); expect(first.status).toBe(401); expect(yield* client.send(input)).toEqual({ unregistered: true }); expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token"); expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token"); }).pipe( Effect.provide( testLayer(requests, [ Response.json({ access_token: "old-token" }), Response.json({}, { status: 401 }), Response.json({ access_token: "fresh-token" }), Response.json( { error: { details: [ { "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", errorCode: "UNREGISTERED", }, ], }, }, { status: 404 }, ), ]), ), ); }); it.effect("rejects oversized data before contacting Firebase", () => { const requests: HttpClientRequest.HttpClientRequest[] = []; return Effect.gen(function* () { const client = yield* FcmClient; const error = yield* client .send({ ...input, data: { body: "漢".repeat(1500) } }) .pipe(Effect.flip); expect(error.operation).toBe("send"); expect(requests).toHaveLength(0); }).pipe(Effect.provide(testLayer(requests, []))); }); });