Skip to content
Source
Sign inSign up

webhookRoute.test.ts

296 lines11.3 KB

1import * as PlatformNodeCrypto from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
4import * as NodeCrypto from "node:crypto";
5import * as Clock from "effect/Clock";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import * as Etag from "effect/http/Etag";
11import * as HttpPlatform from "effect/http/HttpPlatform";
12import * as HttpRouter from "effect/http/HttpRouter";
13import * as HttpApi from "effect/http-api/HttpApi";
14import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
15​
16import { RELAY_HOOK_DELIVERY_TYP, signRelayJwt } from "@t3tools/shared/relayJwt";
17import {
18 EnvironmentHttpApi,
19 EnvironmentId,
20 ScheduledTaskWebhookDeliveryId,
21 ScheduledTaskError,
22} from "@t3tools/contracts";
23import {
24 ScheduledTaskService,
25 type WebhookTriggerRequest,
26 type WebhookTriggerResult,
27} from "./ScheduledTaskService.ts";
28import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
29import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET } from "../cloud/config.ts";
30import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
31import * as RelayDeliveryProof from "./RelayDeliveryProof.ts";
32import { WEBHOOK_MAX_BODY_BYTES } from "./webhookRoute.ts";
33import * as WebhookRoute from "./webhookRoute.ts";
34​
35class WebhookTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.webhooks) {}
36​
37const environmentId = EnvironmentId.make("env-1");
38const relayIssuer = "https://relay.example.test";
39const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
40 privateKeyEncoding: { format: "pem", type: "pkcs8" },
41 publicKeyEncoding: { format: "pem", type: "spki" },
42});
43const linkedSecrets: ReadonlyMap<string, string> = new Map([
44 [CLOUD_MINT_PUBLIC_KEY, mintKeys.publicKey],
45 [RELAY_ISSUER_SECRET, relayIssuer],
46]);
47​
48const relayProof = Effect.fn("relayProof")(function* (claims: {
49 readonly deliveryId: string;
50 readonly receivedAt: string;
51 readonly hookId: string;
52 readonly privateKey?: string;
53}) {
54 const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
55 return yield* signRelayJwt({
56 privateKey: claims.privateKey ?? mintKeys.privateKey,
57 typ: RELAY_HOOK_DELIVERY_TYP,
58 payload: {
59 iss: relayIssuer,
60 aud: `t3-env:${environmentId}`,
61 sub: environmentId,
62 jti: `proof:${claims.deliveryId}`,
63 iat: now,
64 exp: now + 3_600,
65 environmentId,
66 deliveryId: claims.deliveryId,
67 receivedAt: claims.receivedAt,
68 hookId: claims.hookId,
69 },
70 }).pipe(Effect.provide(PlatformNodeCrypto.layerSignature));
71});
72​
73const handlerFor = (
74 trigger: (
75 request: WebhookTriggerRequest,
76 ) => Effect.Effect<WebhookTriggerResult, ScheduledTaskError>,
77 secrets: ReadonlyMap<string, string> = linkedSecrets,
78) =>
79 HttpRouter.toWebHandler(
80 HttpApiBuilder.layer(WebhookTestApi).pipe(
81 Layer.provide(WebhookRoute.layer),
82 Layer.provide(RelayDeliveryProof.layer),
83 Layer.provide(Layer.mock(ScheduledTaskService)({ triggerWebhook: trigger })),
84 Layer.provide(
85 Layer.mock(ServerSecretStore.ServerSecretStore)({
86 get: (name) =>
87 Effect.succeed(
88 Option.map(Option.fromUndefinedOr(secrets.get(name)), (value) =>
89 new TextEncoder().encode(value),
90 ),
91 ),
92 }),
93 ),
94 Layer.provide(
95 Layer.mock(ServerEnvironment.ServerEnvironment)({
96 getEnvironmentId: Effect.succeed(environmentId),
97 }),
98 ),
99 Layer.provide(
100 HttpPlatform.layer.pipe(
101 Layer.provideMerge(NodeServices.layer),
102 Layer.provideMerge(Etag.layerWeak),
103 ),
104 ),
105 Layer.provide([NodeServices.layer, PlatformNodeCrypto.layerSignature]),
106 ),
107 { disableLogger: true },
108 );
109​
110const post = (
111 path: string,
112 body: string | Uint8Array<ArrayBuffer>,
113 headers: Record<string, string> = {},
114) => new Request(`http://env.local${path}`, { method: "POST", body, headers });
115​
116describe("webhook route", () => {
117 it.live("trusts the relay's delivery id and receive time only with its signed proof", () =>
118 Effect.gen(function* () {
119 const received: Array<WebhookTriggerRequest> = [];
120 const { handler, dispose } = handlerFor((request) => {
121 received.push(request);
122 return Effect.succeed({
123 _tag: "accepted",
124 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
125 outcome: "accepted",
126 });
127 });
128 const send = (path: string, headers: Record<string, string>) =>
129 Effect.promise(() => handler(post(path, "{}", headers)));
130 const receivedAt = "2026-10-04T10:00:00.000Z";
131 const relayHeaders = {
132 "x-t3-relay-delivery-id": "relay-1",
133 "x-t3-relay-received-at": receivedAt,
134 };
135 const forged = NodeCrypto.generateKeyPairSync("ed25519", {
136 privateKeyEncoding: { format: "pem", type: "pkcs8" },
137 publicKeyEncoding: { format: "pem", type: "spki" },
138 });
139 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
140 const forgedProof = yield* relayProof({
141 deliveryId: "relay-1",
142 receivedAt,
143 hookId: "id",
144 privateKey: forged.privateKey,
145 });
146 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
147 yield* send("/api/hooks/id/tok", relayHeaders);
148 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": forgedProof });
149 yield* send("/api/hooks/id/tok", {
150 ...relayHeaders,
151 "x-t3-relay-delivery-id": "relay-2",
152 "x-t3-relay-delivery": proof,
153 });
154 yield* send("/api/hooks/other/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
155 yield* Effect.promise(() => dispose());
156 expect(received.map((request) => request.relayDeliveryId)).toEqual([
157 "relay-1",
158 undefined,
159 undefined,
160 undefined,
161 undefined,
162 ]);
163 expect(received[0]?.receivedAt).toBe(receivedAt);
164 expect(received.slice(1).every((request) => request.receivedAt === undefined)).toBe(true);
165 }),
166 );
167​
168 it.live("trusts no relay headers on an environment not linked to T3 Connect", () =>
169 Effect.gen(function* () {
170 const received: Array<WebhookTriggerRequest> = [];
171 const { handler, dispose } = handlerFor((request) => {
172 received.push(request);
173 return Effect.succeed({ _tag: "not_found" });
174 }, new Map());
175 const receivedAt = "2026-10-04T10:00:00.000Z";
176 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
177 yield* Effect.promise(() =>
178 handler(
179 post("/api/hooks/id/tok", "{}", {
180 "x-t3-relay-delivery-id": "relay-1",
181 "x-t3-relay-received-at": receivedAt,
182 "x-t3-relay-delivery": proof,
183 }),
184 ),
185 );
186 yield* Effect.promise(() => dispose());
187 expect(received[0]?.relayDeliveryId).toBeUndefined();
188 }),
189 );
190​
191 it("maps service outcomes to status codes and names each outcome for the relay", async () => {
192 const deliveryId = ScheduledTaskWebhookDeliveryId.make("delivery:1");
193 const cases: ReadonlyArray<[WebhookTriggerResult, number, string]> = [
194 [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"],
195 [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"],
196 [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"],
197 [{ _tag: "accepted", deliveryId, outcome: "filtered" }, 202, "filtered"],
198 [{ _tag: "not_found" }, 404, "not_found"],
199 [{ _tag: "rejected_signature" }, 401, "rejected_signature"],
200 [{ _tag: "disabled" }, 409, "disabled"],
201 [{ _tag: "blocked_by_policy" }, 403, "blocked_by_policy"],
202 [{ _tag: "rate_limited", outcome: "rate_limited" }, 429, "rate_limited"],
203 [{ _tag: "rate_limited", outcome: "queue_full" }, 429, "queue_full"],
204 [{ _tag: "expired" }, 410, "expired"],
205 ];
206 for (const [result, status, outcome] of cases) {
207 const { handler, dispose } = handlerFor(() => Effect.succeed(result));
208 try {
209 const response = await handler(post("/api/hooks/id/tok", "{}"));
210 expect(response.status).toBe(status);
211 expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome);
212 if (status === 429) expect(await response.json()).toEqual({ error: outcome });
213 } finally {
214 await dispose();
215 }
216 }
217 });
218​
219 it("rejects oversized bodies and malformed paths before reaching the service", async () => {
220 let calls = 0;
221 const { handler, dispose } = handlerFor(() => {
222 calls += 1;
223 return Effect.succeed({ _tag: "not_found" });
224 });
225 try {
226 const big = new Uint8Array(WEBHOOK_MAX_BODY_BYTES + 1);
227 expect((await handler(post("/api/hooks/id/tok", big))).status).toBe(413);
228 expect((await handler(post("/api/hooks/id", "{}"))).status).toBe(404);
229 expect((await handler(post("/api/hooks/id/tok/extra", "{}"))).status).toBe(404);
230 expect((await handler(post("/api/hooks/%E0/tok", "{}"))).status).toBe(404);
231 const chunked = new ReadableStream<Uint8Array>({
232 start(controller) {
233 for (let sent = 0; sent <= WEBHOOK_MAX_BODY_BYTES; sent += 64 * 1024) {
234 controller.enqueue(new Uint8Array(64 * 1024));
235 }
236 controller.close();
237 },
238 });
239 const streamed = await handler(
240 new Request("http://env.local/api/hooks/id/tok", {
241 method: "POST",
242 body: chunked,
243 // Node's fetch needs duplex for streamed bodies.
244 duplex: "half",
245 }),
246 );
247 expect(streamed.status).toBe(413);
248 expect(calls).toBe(0);
249 } finally {
250 await dispose();
251 }
252 });
253​
254 it("refuses HEAD probes without triggering the hook", async () => {
255 let calls = 0;
256 const { handler, dispose } = handlerFor(() => {
257 calls += 1;
258 return Effect.succeed({
259 _tag: "accepted",
260 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
261 outcome: "accepted",
262 });
263 });
264 try {
265 const response = await handler(
266 new Request("http://env.local/api/hooks/id/tok", { method: "HEAD" }),
267 );
268 expect(response.status).toBe(405);
269 expect(response.headers.get("allow")).toBe("GET, POST, PUT, PATCH");
270 expect(response.headers.get("x-t3-hook-outcome")).toBe("method_not_allowed");
271 expect(calls).toBe(0);
272 const get = await handler(new Request("http://env.local/api/hooks/id/tok"));
273 expect(get.status).toBe(202);
274 expect(calls).toBe(1);
275 } finally {
276 await dispose();
277 }
278 });
279​
280 it("hides service failures and defects behind a fixed 500", async () => {
281 const failures = [
282 Effect.fail(new ScheduledTaskError({ message: "database locked" })),
283 Effect.die(new Error("database exploded")),
284 ];
285 for (const failure of failures) {
286 const { handler, dispose } = handlerFor(() => failure);
287 try {
288 const response = await handler(post("/api/hooks/id/tok", "{}"));
289 expect(response.status).toBe(500);
290 expect(await response.json()).toEqual({ error: "internal_error" });
291 } finally {
292 await dispose();
293 }
294 }
295 });
296});