1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as r2 from "@distilled.cloud/cloudflare/r2";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Enabling Sippy requires real external-cloud credentials: an AWS S3 (or
20// GCS) source bucket with read credentials, plus an R2 API token with
21// write access for the destination. The testing account ships neither, so
22// the full lifecycle test below is gated behind env-supplied credentials.
23// Without them, `putBucketSippy` fails with the typed
24// `InvalidUpstreamCredentials` (code 10063) — asserted in the ungated test.
25const sippyCreds =
26 !!process.env.TEST_SIPPY_AWS_BUCKET &&
27 !!process.env.TEST_SIPPY_AWS_REGION &&
28 !!process.env.TEST_SIPPY_AWS_ACCESS_KEY_ID &&
29 !!process.env.TEST_SIPPY_AWS_SECRET_ACCESS_KEY &&
30 !!process.env.TEST_SIPPY_R2_ACCESS_KEY_ID &&
31 !!process.env.TEST_SIPPY_R2_SECRET_ACCESS_KEY;
32
33// The scoped API token the test harness mints propagates eventually-
34// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
35// declared in the distilled error union) on out-of-band verification calls.
36const getSippy = (accountId: string, bucketName: string) =>
37 r2.getBucketSippy({ accountId, bucketName }).pipe(
38 Effect.retry({
39 while: (e) => e._tag === "Forbidden",
40 schedule: Schedule.exponential("500 millis"),
41 times: 8,
42 }),
43 );
44
45const program = (opts: { sippy: boolean }) =>
46 Effect.gen(function* () {
47 const bucket = yield* Cloudflare.R2.Bucket("SippyBucket", {
48 forceDestroy: true,
49 });
50 const sippy = opts.sippy
51 ? yield* Cloudflare.R2.BucketSippy("Sippy", {
52 bucketName: bucket.bucketName,
53 source: {
54 provider: "aws",
55 bucket: process.env.TEST_SIPPY_AWS_BUCKET!,
56 region: process.env.TEST_SIPPY_AWS_REGION!,
57 accessKeyId: Redacted.make(
58 process.env.TEST_SIPPY_AWS_ACCESS_KEY_ID!,
59 ),
60 secretAccessKey: Redacted.make(
61 process.env.TEST_SIPPY_AWS_SECRET_ACCESS_KEY!,
62 ),
63 },
64 destination: {
65 accessKeyId: Redacted.make(
66 process.env.TEST_SIPPY_R2_ACCESS_KEY_ID!,
67 ),
68 secretAccessKey: Redacted.make(
69 process.env.TEST_SIPPY_R2_SECRET_ACCESS_KEY!,
70 ),
71 },
72 })
73 : undefined;
74 return { bucket, sippy };
75 });
76
77// Ungated — exercises everything Sippy exposes without external creds:
78// the disabled baseline read, idempotent disable, and the typed failure
79// tags the provider's lifecycle operations rely on.
80test.provider(
81 "reads the disabled baseline and surfaces typed errors without external creds",
82 (stack) =>
83 Effect.gen(function* () {
84 const { accountId } = yield* yield* CloudflareEnvironment;
85
86 yield* stack.destroy();
87
88 const { bucket } = yield* stack.deploy(program({ sippy: false }));
89
90 // A bucket with Sippy never configured reads back `enabled: false` —
91 // the provider's `read` maps this to "resource absent".
92 const baseline = yield* getSippy(accountId, bucket.bucketName);
93 expect(baseline.enabled).toEqual(false);
94
95 // Disabling Sippy when it was never enabled succeeds — the
96 // provider's `delete` is idempotent by construction.
97 const disabled = yield* r2.deleteBucketSippy({
98 accountId,
99 bucketName: bucket.bucketName,
100 });
101 expect(disabled.enabled).toEqual(false);
102
103 // Enabling with bogus upstream credentials fails with the typed
104 // `InvalidUpstreamCredentials` tag (code 10063) — the error a
105 // failed reconcile propagates.
106 const putError = yield* r2
107 .putBucketSippy({
108 accountId,
109 bucketName: bucket.bucketName,
110 source: {
111 provider: "aws",
112 bucket: "alchemy-nonexistent-source",
113 region: "us-east-1",
114 accessKeyId: "AKIAFAKEFAKEFAKEFAKE",
115 secretAccessKey: "fakefakefakefakefakefakefakefakefakefake",
116 },
117 destination: {
118 provider: "r2",
119 accessKeyId: "deadbeefdeadbeefdeadbeefdeadbeef",
120 secretAccessKey:
121 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
122 },
123 })
124 .pipe(Effect.flip);
125 expect(putError._tag).toEqual("InvalidUpstreamCredentials");
126
127 yield* stack.destroy();
128
129 // Once the bucket is gone, the Sippy endpoints report the typed
130 // `NoSuchBucket` (code 10006) — what `read` maps to undefined and
131 // `delete` swallows.
132 const goneError = yield* getSippy(accountId, bucket.bucketName).pipe(
133 Effect.flip,
134 );
135 expect(goneError._tag).toEqual("NoSuchBucket");
136
137 // Destroy again — engine-level delete must be idempotent.
138 yield* stack.destroy();
139 }).pipe(logLevel),
140 {
141 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
142 timeout: 120_000,
143 },
144);
145
146// list() — parent fan-out singleton. Ungated path: without external creds
147// no bucket has Sippy enabled, so the enumeration is a well-typed array that
148// must NOT contain the freshly-deployed (Sippy-disabled) bucket. This still
149// exercises the bucket fan-out + per-item typed skip end-to-end.
150test.provider(
151 "list enumerates buckets that have Sippy enabled",
152 (stack) =>
153 Effect.gen(function* () {
154 yield* stack.destroy();
155
156 const { bucket } = yield* stack.deploy(program({ sippy: false }));
157
158 const provider = yield* Provider.findProvider(Cloudflare.R2.BucketSippy);
159 const all = yield* provider.list();
160
161 expect(Array.isArray(all)).toBe(true);
162 // Sippy was never enabled on this bucket, so it must be absent and
163 // every returned item must report `enabled: true` (matching `read`).
164 expect(all.some((s) => s.bucketName === bucket.bucketName)).toBe(false);
165 expect(all.every((s) => s.enabled === true)).toBe(true);
166
167 yield* stack.destroy();
168 }).pipe(logLevel),
169 {
170 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
171 timeout: 120_000,
172 },
173);
174
175// Gated list() — with real creds the enabled bucket must appear in the
176// account-wide enumeration.
177test.provider.skipIf(!sippyCreds)(
178 "list includes a bucket with Sippy enabled",
179 (stack) =>
180 Effect.gen(function* () {
181 yield* stack.destroy();
182
183 const created = yield* stack.deploy(program({ sippy: true }));
184
185 const provider = yield* Provider.findProvider(Cloudflare.R2.BucketSippy);
186 const all = yield* provider.list();
187
188 expect(
189 all.some(
190 (s) => s.bucketName === created.bucket.bucketName && s.enabled,
191 ),
192 ).toBe(true);
193
194 yield* stack.destroy();
195 }).pipe(logLevel),
196 {
197 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
198 timeout: 300_000,
199 },
200);
201
202// Full lifecycle — requires env-supplied AWS source + R2 destination
203// credentials (see `sippyCreds` above for the exact variables).
204test.provider.skipIf(!sippyCreds)(
205 "enable, no-op redeploy, disable on destroy",
206 (stack) =>
207 Effect.gen(function* () {
208 const { accountId } = yield* yield* CloudflareEnvironment;
209
210 yield* stack.destroy();
211
212 const created = yield* stack.deploy(program({ sippy: true }));
213 expect(created.sippy).toBeDefined();
214 expect(created.sippy!.enabled).toEqual(true);
215 expect(created.sippy!.accountId).toEqual(accountId);
216 expect(created.sippy!.bucketName).toEqual(created.bucket.bucketName);
217 expect(created.sippy!.jurisdiction).toEqual("default");
218 expect(created.sippy!.source.provider).toEqual("aws");
219 expect(created.sippy!.source.bucket).toEqual(
220 process.env.TEST_SIPPY_AWS_BUCKET,
221 );
222 expect(created.sippy!.destination.provider).toEqual("r2");
223
224 // Out-of-band — Sippy is live on the bucket.
225 const live = yield* getSippy(accountId, created.bucket.bucketName);
226 expect(live.enabled).toEqual(true);
227 expect(live.source?.bucket).toEqual(process.env.TEST_SIPPY_AWS_BUCKET);
228
229 // Redeploying identical props re-PUTs the same configuration — a
230 // converging no-op (the PUT is a full upsert).
231 const noop = yield* stack.deploy(program({ sippy: true }));
232 expect(noop.sippy!.enabled).toEqual(true);
233 expect(noop.sippy!.bucketName).toEqual(created.bucket.bucketName);
234
235 // Removing the resource disables Sippy but keeps the bucket (and
236 // any already-migrated objects).
237 const removed = yield* stack.deploy(program({ sippy: false }));
238 expect(removed.bucket.bucketName).toEqual(created.bucket.bucketName);
239 const disabled = yield* getSippy(accountId, removed.bucket.bucketName);
240 expect(disabled.enabled).toEqual(false);
241
242 yield* stack.destroy();
243
244 const goneError = yield* getSippy(
245 accountId,
246 created.bucket.bucketName,
247 ).pipe(Effect.flip);
248 expect(goneError._tag).toEqual("NoSuchBucket");
249 }).pipe(logLevel),
250 {
251 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
252 timeout: 300_000,
253 },
254);