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 @@
1import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";7import * as Redacted from "effect/Redacted";
6import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
7import * as HttpClient from "effect/unstable/http/HttpClient";10import * as HttpClient from "effect/unstable/http/HttpClient";
8import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";11import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
9import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";12import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
@@ -68,6 +71,54 @@ function testLayer(requests: HttpClientRequest.HttpClientRequest[], responses: R · 58 unmodified lines
68}71}
69 72
70describe("FCM delivery", () => {73describe("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
71 it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () =>122 it.effect("signs a verifiable Google OAuth assertion scoped to messaging", () =>
72 Effect.gen(function* () {123 Effect.gen(function* () {
73 const assertion = yield* makeFcmAssertion(account, 1000);124 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
10 10
11import { RelayConfiguration } from "../Config.ts";11import { RelayConfiguration } from "../Config.ts";
12 12
13const FCM_HTTP_STAGE_TIMEOUT = "10 seconds";
14
13const ServiceAccount = Schema.Struct({15const ServiceAccount = Schema.Struct({
14 project_id: Schema.NonEmptyString,16 project_id: Schema.NonEmptyString,
15 client_email: Schema.NonEmptyString,17 client_email: Schema.NonEmptyString,
@@ -125,10 +127,14 @@ export const make = Effect.gen(function* () { · 109 unmodified lines
125 }),127 }),
126 ),128 ),
127 )129 )
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 );
129 if (response.status !== 200)134 if (response.status !== 200)
130 return yield* new FcmClientError({ operation: "authorize", status: response.status });135 return yield* new FcmClientError({ operation: "authorize", status: response.status });
131 return yield* response.json.pipe(136 return yield* response.json.pipe(
137 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
132 Effect.flatMap(decodeAccessToken),138 Effect.flatMap(decodeAccessToken),
133 Effect.map((body) => body.access_token),139 Effect.map((body) => body.access_token),
134 Effect.mapError(140 Effect.mapError(
@@ -165,11 +171,15 @@ export const make = Effect.gen(function* () { · 30 unmodified lines
165 },171 },
166 }),172 }),
167 Effect.flatMap(client.execute),173 Effect.flatMap(client.execute),
174 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
168 Effect.mapError(() => new FcmClientError({ operation: "send", status: null })),175 Effect.mapError(() => new FcmClientError({ operation: "send", status: null })),
169 );176 );
170 if (response.status >= 200 && response.status < 300) return { unregistered: false };177 if (response.status >= 200 && response.status < 300) return { unregistered: false };
171 if (response.status === 401) yield* invalidateToken;178 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 );
173 const decoded = decodeFcmError(body);183 const decoded = decodeFcmError(body);
174 const unregistered =184 const unregistered =
175 Option.isSome(decoded) &&185 Option.isSome(decoded) &&