History

fix(relay): bound Firebase authorization and delivery requests

Ryan Hughes committed 2 months ago

Commit ec01afc1 parent 7c1d61f2 files · +63 −2Browse files

Unified · Split

2 files
  1. infra/relay/src/agentActivity/FcmClient.test.ts +51 −0
  2. infra/relay/src/agentActivity/FcmClient.ts +12 −2
infra/relay/src/agentActivity/FcmClient.test.ts+51−0
@@ -1,9 +1,12 @@
11 import * as NodeCrypto from "node:crypto";
22 import { describe, expect, it } from "@effect/vitest";
3+import * as Deferred from "effect/Deferred";
34 import * as Effect from "effect/Effect";
5+import * as Fiber from "effect/Fiber";
46 import * as Layer from "effect/Layer";
57 import * as Redacted from "effect/Redacted";
68 import * as Schema from "effect/Schema";
9+import * as TestClock from "effect/testing/TestClock";
710 import * as HttpClient from "effect/unstable/http/HttpClient";
811 import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
912 import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
@@ -68,6 +71,54 @@ function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: R · 58 unmodified lines
6871 }
6972
7073 describe("FCM delivery", () => {
74+ it.effect.each([
75+ { operation: "authorize", stage: "headers", status: null },
76+ { operation: "authorize", stage: "body", status: 200 },
77+ { operation: "send", stage: "headers", status: null },
78+ { operation: "send", stage: "body", status: 503 },
79+ ] as const)("bounds a stalled $operation $stage and allows the next delivery", (scenario) =>
80+ Effect.gen(function* () {
81+ const started = yield* Deferred.make<void>();
82+ let shouldStall = true;
83+ const http = HttpClient.make((request) => {
84+ const isAuthorization = request.url === "https://oauth2.googleapis.com/token";
85+ const stall = shouldStall && isAuthorization === (scenario.operation === "authorize");
86+ const response = HttpClientResponse.fromWeb(
87+ request,
88+ isAuthorization
89+ ? Response.json({ access_token: "access-token" })
90+ : Response.json({}, { status: stall ? 503 : 200 }),
91+ );
92+ if (stall) {
93+ shouldStall = false;
94+ const stalled = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never));
95+ if (scenario.stage === "headers") return stalled;
96+ Object.defineProperty(response, "json", { value: stalled });
97+ }
98+ return Effect.succeed(response);
99+ });
100+ yield* Effect.gen(function* () {
101+ const client = yield* FcmClient;
102+ const delivery = yield* client.send(input).pipe(Effect.flip, Effect.forkChild);
103+ yield* Deferred.await(started);
104+ yield* TestClock.adjust("10 seconds");
105+ expect(yield* Fiber.join(delivery)).toMatchObject({
106+ _tag: "FcmClientError",
107+ operation: scenario.operation,
108+ status: scenario.status,
109+ });
110+ expect(yield* client.send(input)).toEqual({ unregistered: false });
111+ }).pipe(
112+ Effect.provide(
113+ layer.pipe(
114+ Layer.provide(Layer.succeed(RelayConfiguration, config)),
115+ Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
116+ ),
117+ ),
118+ );
119+ }),
120+ );
121+
71122 it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () =>
72123 Effect.gen(function* () {
73124 const assertion = yield* makeFcmAssertion(account, 1000);
infra/relay/src/agentActivity/FcmClient.ts+12−2
@@ -10,6 +10,8 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; · 9 unmodified lines
1010
1111 import { RelayConfiguration } from "../Config.ts";
1212
13+const FCM_HTTP_STAGE_TIMEOUT = "10 seconds";
14+
1315 const ServiceAccount = Schema.Struct({
1416 project_id: Schema.NonEmptyString,
1517 client_email: Schema.NonEmptyString,
@@ -125,10 +127,14 @@ export const make = Effect.gen(function* () { · 109 unmodified lines
125127 }),
126128 ),
127129 )
128− .pipe(Effect.mapError(() => new FcmClientError({ operation: "authorize", status: null })));
130+ .pipe(
131+ Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
132+ Effect.mapError(() => new FcmClientError({ operation: "authorize", status: null })),
133+ );
129134 if (response.status !== 200)
130135 return yield* new FcmClientError({ operation: "authorize", status: response.status });
131136 return yield* response.json.pipe(
137+ Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
132138 Effect.flatMap(decodeAccessToken),
133139 Effect.map((body) => body.access_token),
134140 Effect.mapError(
@@ -165,11 +171,15 @@ export const make = Effect.gen(function* () { · 30 unmodified lines
165171 },
166172 }),
167173 Effect.flatMap(client.execute),
174+ Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
168175 Effect.mapError(() => new FcmClientError({ operation: "send", status: null })),
169176 );
170177 if (response.status >= 200 && response.status < 300) return { unregistered: false };
171178 if (response.status === 401) yield* invalidateToken;
172− const body = yield* response.json.pipe(Effect.orElseSucceed(() => null));
179+ const body = yield* response.json.pipe(
180+ Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
181+ Effect.orElseSucceed(() => null),
182+ );
173183 const decoded = decodeFcmError(body);
174184 const unregistered =
175185 Option.isSome(decoded) &&