Skip to content
Source
Sign inSign up
Open History to see the last commit

FcmClient.test.ts

196 lines7.0 KB

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 Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientResponse from "effect/http/HttpClientResponse";
10import type * as HttpClientRequest from "effect/http/HttpClientRequest";
11​
12import * as RelayConfiguration from "../Config.ts";
13import * as FcmClient from "./FcmClient.ts";
14​
15import * as WebCrypto from "../WebCrypto.ts";
16import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
17​
18const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", {
19 modulusLength: 2048,
20 privateKeyEncoding: { type: "pkcs8", format: "pem" },
21 publicKeyEncoding: { type: "spki", format: "pem" },
22});
23const account = {
24 project_id: "test-project",
25 client_email: "push@test-project.iam.gserviceaccount.com",
26 private_key: privateKey,
27};
28const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
29const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
30const config = {
31 relayIssuer: "https://relay.test",
32 apns: {
33 environment: "sandbox",
34 teamId: "team",
35 keyId: "key",
36 bundleId: "app",
37 privateKey: Redacted.make("unused"),
38 },
39 fcmServiceAccount: Redacted.make(encodeJson(account)),
40 clerkSecretKey: Redacted.make("unused"),
41 clerkPublishableKey: "unused",
42 clerkJwtAudience: "unused",
43 apnsDeliveryJobSigningSecret: Redacted.make("unused"),
44 cloudMintPrivateKey: Redacted.make("unused"),
45 cloudMintPublicKey: "unused",
46 managedEndpointBaseDomain: undefined,
47 managedEndpointNamespace: undefined,
48} satisfies RelayConfiguration.RelayConfiguration["Service"];
49const input = {
50 token: "device-token",
51 packageName: "com.t3tools.t3code.dev",
52 data: { t3_kind: "agent_activity", active: "true" },
53 alert: false,
54};
55​
56function layerTest(requests: HttpClientRequest.HttpClientRequest[], responses: Response[]) {
57 const http = HttpClient.make((request) => {
58 requests.push(request);
59 const response = responses.shift();
60 return response
61 ? Effect.succeed(HttpClientResponse.fromWeb(request, response))
62 : Effect.die("unexpected request");
63 });
64 return FcmClient.layer.pipe(
65 Layer.provide(
66 FcmAssertionSigner.layer.pipe(
67 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
68 ),
69 ),
70 Layer.provide(
71 Layer.mergeAll(
72 Layer.succeed(RelayConfiguration.RelayConfiguration, config),
73 Layer.succeed(HttpClient.HttpClient, http),
74 ),
75 ),
76 );
78​
79describe("FCM delivery", () => {
80 it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () =>
81 Effect.gen(function* () {
82 const signer = yield* FcmAssertionSigner.FcmAssertionSigner;
83 const assertion = yield* signer.sign({
84 privateKey: account.private_key,
85 clientEmail: account.client_email,
86 issuedAt: 1000,
87 });
88 const [header, claims, signature] = assertion.split(".");
89 expect(
90 NodeCrypto.verify(
91 "RSA-SHA256",
92 Buffer.from(`${header}.${claims}`),
93 publicKey,
94 Buffer.from(signature!, "base64url"),
95 ),
96 ).toBe(true);
97 expect(decodeJson(Buffer.from(claims!, "base64url").toString())).toEqual({
98 iss: account.client_email,
99 scope: "https://www.googleapis.com/auth/firebase.messaging",
100 aud: "https://oauth2.googleapis.com/token",
101 iat: 1000,
102 exp: 4600,
103 });
104 }).pipe(
105 Effect.provide(
106 FcmAssertionSigner.layer.pipe(
107 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
108 ),
109 ),
110 ),
111 );
112​
113 it.effect(
114 "reuses OAuth authorization and sends native data messages to the correct package",
115 () => {
116 const requests: HttpClientRequest.HttpClientRequest[] = [];
117 return Effect.gen(function* () {
118 const client = yield* FcmClient.FcmClient;
119 yield* client.send(input);
120 yield* client.send({ ...input, alert: true });
121 expect(requests.map((request) => request.url)).toEqual([
122 "https://oauth2.googleapis.com/token",
123 "https://fcm.googleapis.com/v1/projects/test-project/messages:send",
124 "https://fcm.googleapis.com/v1/projects/test-project/messages:send",
125 ]);
126 expect(requests[1]!.headers.authorization).toBe("Bearer access-token");
127 const body = requests[1]!.body;
128 if (body._tag !== "Uint8Array") throw new Error("Expected encoded FCM body");
129 expect(decodeJson(new TextDecoder().decode(body.body))).toEqual({
130 message: {
131 token: input.token,
132 data: input.data,
133 android: {
134 priority: "HIGH",
135 ttl: "300s",
136 collapse_key: "t3-agent-activity",
137 restricted_package_name: input.packageName,
138 },
139 },
140 });
141 }).pipe(
142 Effect.provide(
143 layerTest(requests, [
144 Response.json({ access_token: "access-token" }),
145 Response.json({ name: "one" }),
146 Response.json({ name: "two" }),
147 ]),
148 ),
149 );
150 },
151 );
152​
153 it.effect("invalidates authorization after 401 and recognizes unregistered device tokens", () => {
154 const requests: HttpClientRequest.HttpClientRequest[] = [];
155 return Effect.gen(function* () {
156 const client = yield* FcmClient.FcmClient;
157 const first = yield* client.send(input).pipe(Effect.flip);
158 expect(first.status).toBe(401);
159 expect(yield* client.send(input)).toEqual({ unregistered: true });
160 expect(requests[2]!.url).toBe("https://oauth2.googleapis.com/token");
161 expect(requests[3]!.headers.authorization).toBe("Bearer fresh-token");
162 }).pipe(
163 Effect.provide(
164 layerTest(requests, [
165 Response.json({ access_token: "old-token" }),
166 Response.json({}, { status: 401 }),
167 Response.json({ access_token: "fresh-token" }),
168 Response.json(
169 {
170 error: {
171 details: [
172 {
173 "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
174 errorCode: "UNREGISTERED",
175 },
176 ],
177 },
178 },
179 { status: 404 },
180 ),
181 ]),
182 ),
183 );
184 });
185 it.effect("rejects oversized data before contacting Firebase", () => {
186 const requests: HttpClientRequest.HttpClientRequest[] = [];
187 return Effect.gen(function* () {
188 const client = yield* FcmClient.FcmClient;
189 const error = yield* client
190 .send({ ...input, data: { body: "漢".repeat(1500) } })
191 .pipe(Effect.flip);
192 expect(error.operation).toBe("send");
193 expect(requests).toHaveLength(0);
194 }).pipe(Effect.provide(layerTest(requests, [])));
195 });
196});