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