| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or verify. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import * as HttpClient from "effect/http/HttpClient"; |
| 12 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 13 | import type * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 14 | |
| 15 | import * as RelayConfiguration from "../Config.ts"; |
| 16 | import * as FcmClient from "./FcmClient.ts"; |
| 17 | |
| 18 | import * as WebCrypto from "../WebCrypto.ts"; |
| 19 | import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; |
| 20 | |
| 21 | const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", { |
| 22 | modulusLength: 2048, |
| 23 | privateKeyEncoding: { type: "pkcs8", format: "pem" }, |
| 24 | publicKeyEncoding: { type: "spki", format: "pem" }, |
| 25 | }); |
| 26 | const account = { |
| 27 | project_id: "test-project", |
| 28 | client_email: "push@test-project.iam.gserviceaccount.com", |
| 29 | private_key: privateKey, |
| 30 | }; |
| 31 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 32 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 33 | const config = { |
| 34 | relayIssuer: "https://relay.test", |
| 35 | apns: { |
| 36 | environment: "sandbox", |
| 37 | teamId: "team", |
| 38 | keyId: "key", |
| 39 | bundleId: "app", |
| 40 | privateKey: Redacted.make("unused"), |
| 41 | }, |
| 42 | fcmServiceAccount: Redacted.make(encodeJson(account)), |
| 43 | clerkSecretKey: Redacted.make("unused"), |
| 44 | clerkPublishableKey: "unused", |
| 45 | clerkJwtAudience: "unused", |
| 46 | apnsDeliveryJobSigningSecret: Redacted.make("unused"), |
| 47 | cloudMintPrivateKey: Redacted.make("unused"), |
| 48 | cloudMintPublicKey: "unused", |
| 49 | managedEndpointBaseDomain: undefined, |
| 50 | managedEndpointNamespace: undefined, |
| 51 | } satisfies RelayConfiguration.RelayConfiguration["Service"]; |
| 52 | const input = { |
| 53 | token: "device-token", |
| 54 | packageName: "com.t3tools.t3code.dev", |
| 55 | data: { t3_kind: "agent_activity", active: "true" }, |
| 56 | alert: false, |
| 57 | }; |
| 58 | |
| 59 | function layerTest(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) { |
| 60 | const http = HttpClient.make((request) => { |
| 61 | requests.push(request); |
| 62 | const response = responses.shift(); |
| 63 | return response |
| 64 | ? Effect.succeed(HttpClientResponse.fromWeb(request, response)) |
| 65 | : Effect.die("unexpected request"); |
| 66 | }); |
| 67 | return FcmClient.layer.pipe( |
| 68 | Layer.provide( |
| 69 | FcmAssertionSigner.layer.pipe( |
| 70 | Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), |
| 71 | ), |
| 72 | ), |
| 73 | Layer.provide( |
| 74 | Layer.mergeAll( |
| 75 | Layer.succeed(RelayConfiguration.RelayConfiguration, config), |
| 76 | Layer.succeed(HttpClient.HttpClient, http), |
| 77 | ), |
| 78 | ), |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | describe("FCM delivery", () => { |
| 83 | it.effect.each([ |
| 84 | { operation: "authorize", stage: "headers", status: null }, |
| 85 | { operation: "authorize", stage: "body", status: 200 }, |
| 86 | { operation: "send", stage: "headers", status: null }, |
| 87 | { operation: "send", stage: "body", status: 503 }, |
| 88 | ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) => |
| 89 | Effect.gen(function* () { |
| 90 | const started = yield* Deferred.make<void>(); |
| 91 | let shouldStall = true; |
| 92 | const http = HttpClient.make((request) => { |
| 93 | const isAuthorization = request.url === "https://oauth2.googleapis.com/token"; |
| 94 | const stall = shouldStall && isAuthorization === (scenario.operation === "authorize"); |
| 95 | const response = HttpClientResponse.fromWeb( |
| 96 | request, |
| 97 | isAuthorization |
| 98 | ? Response.json({ access_token: "access-token" }) |
| 99 | : Response.json({}, { status: stall ? 503 : 200 }), |
| 100 | ); |
| 101 | if (stall) { |
| 102 | shouldStall = false; |
| 103 | const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); |
| 104 | if (scenario.stage === "headers") return stalled; |
| 105 | Object.defineProperty(response, "json", { value: stalled }); |
| 106 | } |
| 107 | return Effect.succeed(response); |
| 108 | }); |
| 109 | yield* Effect.gen(function* () { |
| 110 | const client = yield* FcmClient.FcmClient; |
| 111 | const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild); |
| 112 | yield* Deferred.await(started); |
| 113 | yield* TestClock.adjust("10 seconds"); |
| 114 | expect(yield* Fiber.join(delivery)).toMatchObject({ |
| 115 | _tag: "FcmClientError", |
| 116 | operation: scenario.operation, |
| 117 | status: scenario.status, |
| 118 | cause: expect.objectContaining({ _tag: "TimeoutError" }), |
| 119 | }); |
| 120 | expect(yield* client.send(input)).toEqual({ unregistered: false }); |
| 121 | }).pipe( |
| 122 | Effect.provide( |
| 123 | FcmClient.layer.pipe( |
| 124 | Layer.provide( |
| 125 | FcmAssertionSigner.layer.pipe( |
| 126 | Layer.provide( |
| 127 | Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle }), |
| 128 | ), |
| 129 | ), |
| 130 | ), |
| 131 | Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), |
| 132 | Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), |
| 133 | ), |
| 134 | ), |
| 135 | ); |
| 136 | }), |
| 137 | ); |
| 138 | |
| 139 | it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () => |
| 140 | Effect.gen(function* () { |
| 141 | const signer = yield* FcmAssertionSigner.FcmAssertionSigner; |
| 142 | const assertion = yield* signer.sign({ |
| 143 | privateKey: account.private_key, |
| 144 | clientEmail: account.client_email, |
| 145 | issuedAt: 1000, |
| 146 | }); |
| 147 | const [header, claims, signature] = assertion.split("."); |
| 148 | expect( |
| 149 | NodeCrypto.verify( |
| 150 | "RSA-SHA256", |
| 151 | Buffer.from(`${header}.${claims}`), |
| 152 | publicKey, |
| 153 | Buffer.from(signature!, "base64url"), |
| 154 | ), |
| 155 | ).toBe(true); |
| 156 | expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({ |
| 157 | iss: account.client_email, |
| 158 | scope: "https://www.googleapis.com/auth/firebase.messaging", |
| 159 | aud: "https://oauth2.googleapis.com/token", |
| 160 | iat: 1000, |
| 161 | exp: 4600, |
| 162 | }); |
| 163 | }).pipe( |
| 164 | Effect.provide( |
| 165 | FcmAssertionSigner.layer.pipe( |
| 166 | Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })), |
| 167 | ), |
| 168 | ), |
| 169 | ), |
| 170 | ); |
| 171 | |
| 172 | it.effect( |
| 173 | "reuses OAuth authorization and sends native data messages to the correct package", |
| 174 | () => { |
| 175 | const requests: HttpClientRequest.HttpClientRequest[] = []; |
| 176 | return Effect.gen(function* () { |
| 177 | const client = yield* FcmClient.FcmClient; |
| 178 | yield* client.send(input); |
| 179 | yield* client.send({ ...input, alert: true }); |
| 180 | expect(requests.map((request) => request.url)).toEqual([ |
| 181 | "https://oauth2.googleapis.com/token", |
| 182 | "https://fcm.googleapis.com/v1/projects/test-project/messages:send", |
| 183 | "https://fcm.googleapis.com/v1/projects/test-project/messages:send", |
| 184 | ]); |
| 185 | expect(requests[1]!.headers.authorization).toBe("Bearer access-token"); |
| 186 | const body = requests[1]!.body; |
| 187 | if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body"); |
| 188 | expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({ |
| 189 | message: { |
| 190 | token: input.token, |
| 191 | data: input.data, |
| 192 | android: { |
| 193 | priority: "HIGH", |
| 194 | ttl: "300s", |
| 195 | collapse_key: "t3-agent-activity", |
| 196 | restricted_package_name: input.packageName, |
| 197 | }, |
| 198 | }, |
| 199 | }); |
| 200 | }).pipe( |
| 201 | Effect.provide( |
| 202 | layerTest(requests, [ |
| 203 | Response.json({ access_token: "access-token" }), |
| 204 | Response.json({ name: "one" }), |
| 205 | Response.json({ name: "two" }), |
| 206 | ]), |
| 207 | ), |
| 208 | ); |
| 209 | }, |
| 210 | ); |
| 211 | |
| 212 | it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => { |
| 213 | const requests: HttpClientRequest.HttpClientRequest[] = []; |
| 214 | return Effect.gen(function* () { |
| 215 | const client = yield* FcmClient.FcmClient; |
| 216 | const first = yield* client.send(input).pipe(Effect.flip); |
| 217 | expect(first.status).toBe(401); |
| 218 | expect(yield* client.send(input)).toEqual({ unregistered: true }); |
| 219 | expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token"); |
| 220 | expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token"); |
| 221 | }).pipe( |
| 222 | Effect.provide( |
| 223 | layerTest(requests, [ |
| 224 | Response.json({ access_token: "old-token" }), |
| 225 | Response.json({}, { status: 401 }), |
| 226 | Response.json({ access_token: "fresh-token" }), |
| 227 | Response.json( |
| 228 | { |
| 229 | error: { |
| 230 | details: [ |
| 231 | { |
| 232 | "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", |
| 233 | errorCode: "UNREGISTERED", |
| 234 | }, |
| 235 | ], |
| 236 | }, |
| 237 | }, |
| 238 | { status: 404 }, |
| 239 | ), |
| 240 | ]), |
| 241 | ), |
| 242 | ); |
| 243 | }); |
| 244 | it.effect("rejects oversized data before contacting Firebase", () => { |
| 245 | const requests: HttpClientRequest.HttpClientRequest[] = []; |
| 246 | return Effect.gen(function* () { |
| 247 | const client = yield* FcmClient.FcmClient; |
| 248 | const error = yield* client |
| 249 | .send({ ...input, data: { body: "漢".repeat(1500) } }) |
| 250 | .pipe(Effect.flip); |
| 251 | expect(error.operation).toBe("send"); |
| 252 | expect(requests).toHaveLength(0); |
| 253 | }).pipe(Effect.provide(layerTest(requests, []))); |
| 254 | }); |
| 255 | }); |