1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or verify.
2import * as NodeCrypto from "node:crypto";
3import { describe, expect, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";
13import type * as HttpClientRequest from "effect/http/HttpClientRequest";
14
15import * as RelayConfiguration from "../Config.ts";
16import * as FcmClient from "./FcmClient.ts";
17
18import * as WebCrypto from "../WebCrypto.ts";
19import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
20
21const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", {
22 modulusLength: 2048,
23 privateKeyEncoding: { type: "pkcs8", format: "pem" },
24 publicKeyEncoding: { type: "spki", format: "pem" },
25});
26const account = {
27 project_id: "test-project",
28 client_email: "push@test-project.iam.gserviceaccount.com",
29 private_key: privateKey,
30};
31const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
32const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
33const 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"];
52const input = {
53 token: "device-token",
54 packageName: "com.t3tools.t3code.dev",
55 data: { t3_kind: "agent_activity", active: "true" },
56 alert: false,
57};
58
59function 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
82describe("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});