1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import type { HttpClientResponse } from "effect/http/HttpClientResponse";
10import Stack from "./fixtures/stack.ts";
11import { SECRET_VALUE } from "./fixtures/secret.ts";
12
13const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
14 providers: Cloudflare.providers(),
15 state: Cloudflare.state(),
16});
17
18const HOOK_TIMEOUT = 300_000;
19const TEST_TIMEOUT = 120_000;
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const stack = beforeAll(deploy(Stack), { timeout: HOOK_TIMEOUT });
27afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
28 timeout: HOOK_TIMEOUT,
29});
30
31// Fresh `workers.dev` URLs take a few seconds to start serving 200s, so the
32// first request rides this bounded schedule. Capping at 30 recurs surfaces a
33// genuine failure fast instead of an uncapped exponential blowing past the
34// test timeout.
35class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
36 status: number;
37}> {}
38
39const ready = Schedule.max([
40 Schedule.exponential("500 millis"),
41 Schedule.recurs(30),
42]);
43
44const fetchWhenReady = (url: string) =>
45 Effect.gen(function* () {
46 const client = yield* HttpClient.HttpClient;
47 return yield* client.get(url).pipe(
48 Effect.flatMap((res: HttpClientResponse) =>
49 res.status === 200
50 ? Effect.succeed(res)
51 : Effect.fail(new WorkerNotReady({ status: res.status })),
52 ),
53 Effect.retry({
54 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
55 schedule: ready,
56 }),
57 );
58 });
59
60const assertSecret = (url: string) =>
61 Effect.gen(function* () {
62 const res = yield* fetchWhenReady(`${url}/secret`);
63 expect(res.status).toBe(200);
64 const body = (yield* res.json) as {
65 value: string;
66 viaGet: string;
67 viaRaw: string;
68 };
69 expect(body.value).toBe(SECRET_VALUE);
70 expect(body.viaGet).toBe(SECRET_VALUE);
71 expect(body.viaRaw).toBe(SECRET_VALUE);
72 });
73
74// Style (1): `Cloudflare.SecretsStore.ReadSecret(secret)` inside the Worker
75// init, binding provided via `ReadSecretBinding`. Drives the full
76// `ReadSecretClient` surface (direct Effect, `.get()`, `.raw`).
77test(
78 "effect-worker: ReadSecret client round-trips the secret value at runtime",
79 Effect.gen(function* () {
80 const { effect } = yield* stack;
81 expect(effect).toBeTypeOf("string");
82 yield* assertSecret(effect);
83 }).pipe(logLevel),
84 {
85 tags: [
86 "provider:cloudflare",
87 "provider:cloudflare:secretsstore",
88 "provider:cloudflare:worker",
89 "live",
90 ],
91 timeout: TEST_TIMEOUT,
92 },
93);
94
95// Style (2): the same Secret declared on the Worker `env` async binding. The
96// Worker provider must map it to a real `secrets_store_secret` binding so the
97// runtime sees a `SecretsStoreSecret` with `.get()`, not a JSON blob.
98test(
99 "async-worker: Secret on the Worker env round-trips as a SecretsStoreSecret",
100 Effect.gen(function* () {
101 const { async } = yield* stack;
102 expect(async).toBeTypeOf("string");
103 yield* assertSecret(async);
104 }).pipe(logLevel),
105 {
106 tags: [
107 "provider:cloudflare",
108 "provider:cloudflare:secretsstore",
109 "provider:cloudflare:worker",
110 "live",
111 ],
112 timeout: TEST_TIMEOUT,
113 },
114);