1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as storage from "@distilled.cloud/gcp/storage_v1";
5import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import { callProbe, dockerAvailable, expectProbe } from "../bindingHost.ts";
11import StorageBindingsHost, {
12 DeleteAssets,
13 GetAssets,
14 PutAssets,
15 ReadAssets,
16 ReadWriteAssets,
17 SEED_KEY,
18 SEED_TEXT,
19 SignAssets,
20 WriteAssets,
21} from "./fixtures/bindings-host.ts";
22
23const testOptions = { providers: GCP.providers() };
24const { test, beforeAll, afterAll } = Test.make(testOptions);
25const sharedStack = Core.scratchStack(testOptions, "StorageBindings");
26
27let baseUrl: string;
28let hostAccount: string;
29let project: string;
30let buckets: {
31 read: string;
32 write: string;
33 readWrite: string;
34 get: string;
35 put: string;
36 delete: string;
37 sign: string;
38};
39
40/** Roles the host's service account holds on one bucket's IAM policy. */
41const bucketRoles = (bucket: string) =>
42 storage
43 .getIamPolicyBuckets({ bucket, optionsRequestedPolicyVersion: 3 })
44 .pipe(
45 Effect.map((policy) =>
46 (policy.bindings ?? [])
47 .filter((binding) =>
48 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
49 )
50 .map((binding) => binding.role)
51 .sort(),
52 ),
53 );
54
55const objectOf = (bucket: string, object: string) =>
56 storage
57 .getObjects({ bucket, object })
58 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
59
60describe.skipIf(!dockerAvailable)(
61 "Storage Bindings",
62 {
63 tags: ["provider:gcp", "provider:gcp:storage", "provider:gcp:run", "live"],
64 },
65 () => {
66 beforeAll(
67 Effect.gen(function* () {
68 yield* sharedStack.destroy();
69 const out = yield* sharedStack.deploy(
70 Effect.gen(function* () {
71 const host = yield* StorageBindingsHost;
72 return {
73 uri: host.uri,
74 serviceAccount: host.serviceAccount,
75 project: host.project,
76 read: (yield* ReadAssets).bucketName,
77 write: (yield* WriteAssets).bucketName,
78 readWrite: (yield* ReadWriteAssets).bucketName,
79 get: (yield* GetAssets).bucketName,
80 put: (yield* PutAssets).bucketName,
81 delete: (yield* DeleteAssets).bucketName,
82 sign: (yield* SignAssets).bucketName,
83 };
84 }),
85 );
86 baseUrl = out.uri!;
87 hostAccount = out.serviceAccount!;
88 project = out.project;
89 buckets = {
90 read: out.read,
91 write: out.write,
92 readWrite: out.readWrite,
93 get: out.get,
94 put: out.put,
95 delete: out.delete,
96 sign: out.sign,
97 };
98 }),
99 { timeout: 900_000 },
100 );
101
102 afterAll(sharedStack.destroy(), { timeout: 600_000 });
103
104 describe("ReadBucket", () => {
105 test.provider(
106 "head, get and list as the host, with objectViewer on the bucket only",
107 (_stack) =>
108 Effect.gen(function* () {
109 const out = yield* expectProbe<{
110 headName: string;
111 headSize: string;
112 text: string;
113 contentType: string;
114 listed: string[];
115 missingHead: boolean;
116 missingGet: boolean;
117 }>(baseUrl, "readBucket");
118 expect(out.headName).toEqual(SEED_KEY);
119 expect(out.headSize).toEqual(String(SEED_TEXT.length));
120 expect(out.text).toEqual(SEED_TEXT);
121 expect(out.contentType).toEqual("text/plain");
122 expect(out.listed).toEqual([SEED_KEY]);
123 expect(out.missingHead).toEqual(true);
124 expect(out.missingGet).toEqual(true);
125
126 expect(yield* bucketRoles(buckets.read)).toEqual([
127 "roles/storage.objectViewer",
128 ]);
129 }),
130 {
131 tags: ["provider:gcp", "provider:gcp:storage", "live"],
132 timeout: 600_000,
133 },
134 );
135 });
136
137 describe("WriteBucket", () => {
138 test.provider(
139 "put and delete as the host, with objectUser on the bucket only",
140 (_stack) =>
141 Effect.gen(function* () {
142 const put = yield* expectProbe<{ name: string }>(
143 baseUrl,
144 "writeBucketPut",
145 );
146 expect(put.name).toEqual("written.txt");
147 const written = yield* objectOf(buckets.write, "written.txt");
148 expect(written?.size).toEqual(
149 String("hello from WriteBucket".length),
150 );
151 expect(written?.contentType).toEqual("text/plain");
152 expect(written?.metadata).toEqual({ source: "write-bucket" });
153
154 const deleted = yield* expectProbe<{ deleted: string }>(
155 baseUrl,
156 "writeBucketDelete",
157 );
158 expect(deleted.deleted).toEqual("written.txt");
159 expect(yield* objectOf(buckets.write, "written.txt")).toEqual(
160 undefined,
161 );
162
163 expect(yield* bucketRoles(buckets.write)).toEqual([
164 "roles/storage.objectUser",
165 ]);
166 }),
167 {
168 tags: ["provider:gcp", "provider:gcp:storage", "live"],
169 timeout: 600_000,
170 },
171 );
172 });
173
174 describe("ReadWriteBucket", () => {
175 test.provider(
176 "put, get, list and delete as the host, with objectUser on the bucket only",
177 (_stack) =>
178 Effect.gen(function* () {
179 const out = yield* expectProbe<{
180 putName: string;
181 text: string;
182 listed: string[];
183 }>(baseUrl, "readWriteBucket");
184 expect(out.putName).toEqual("round-trip.txt");
185 expect(out.text).toEqual("hello from ReadWriteBucket");
186 expect(out.listed).toEqual(["round-trip.txt"]);
187 const written = yield* objectOf(
188 buckets.readWrite,
189 "round-trip.txt",
190 );
191 expect(written?.size).toEqual(
192 String("hello from ReadWriteBucket".length),
193 );
194
195 const deleted = yield* expectProbe<{ gone: boolean }>(
196 baseUrl,
197 "readWriteBucketDelete",
198 );
199 expect(deleted.gone).toEqual(true);
200 expect(
201 yield* objectOf(buckets.readWrite, "round-trip.txt"),
202 ).toEqual(undefined);
203
204 expect(yield* bucketRoles(buckets.readWrite)).toEqual([
205 "roles/storage.objectUser",
206 ]);
207 }),
208 {
209 tags: ["provider:gcp", "provider:gcp:storage", "live"],
210 timeout: 600_000,
211 },
212 );
213 });
214
215 describe("GetObject", () => {
216 test.provider(
217 "downloads the seeded object as the host, with objectViewer on the bucket only",
218 (_stack) =>
219 Effect.gen(function* () {
220 const out = yield* expectProbe<{
221 text: string;
222 contentType: string;
223 }>(baseUrl, "getObject");
224 expect(out.text).toEqual(SEED_TEXT);
225 expect(out.contentType).toEqual("text/plain");
226
227 const missing = yield* callProbe(baseUrl, "getObjectMissing");
228 expect(missing.ok ? undefined : missing.error._tag).toEqual(
229 "GCP.Storage.ObjectNotFound",
230 );
231
232 expect(yield* bucketRoles(buckets.get)).toEqual([
233 "roles/storage.objectViewer",
234 ]);
235 }),
236 {
237 tags: ["provider:gcp", "provider:gcp:storage", "live"],
238 timeout: 600_000,
239 },
240 );
241 });
242
243 describe("PutObject", () => {
244 test.provider(
245 "uploads bytes with metadata as the host, with objectUser on the bucket only",
246 (_stack) =>
247 Effect.gen(function* () {
248 const out = yield* expectProbe<{
249 name: string;
250 metadata: Record<string, string>;
251 }>(baseUrl, "putObject");
252 expect(out.name).toEqual("put.bin");
253 expect(out.metadata).toEqual({ source: "put-object" });
254
255 const written = yield* objectOf(buckets.put, "put.bin");
256 expect(written?.size).toEqual(
257 String("hello from PutObject".length),
258 );
259 expect(written?.contentType).toEqual("application/octet-stream");
260 expect(written?.metadata).toEqual({ source: "put-object" });
261
262 expect(yield* bucketRoles(buckets.put)).toEqual([
263 "roles/storage.objectUser",
264 ]);
265 }),
266 {
267 tags: ["provider:gcp", "provider:gcp:storage", "live"],
268 timeout: 600_000,
269 },
270 );
271 });
272
273 describe("DeleteObject", () => {
274 test.provider(
275 "deletes the seeded object as the host, with objectUser on the bucket only",
276 (_stack) =>
277 Effect.gen(function* () {
278 const seed = yield* objectOf(buckets.delete, SEED_KEY);
279 expect(seed?.name).toEqual(SEED_KEY);
280
281 const out = yield* expectProbe<{ deleted: string }>(
282 baseUrl,
283 "deleteObject",
284 );
285 expect(out.deleted).toEqual(SEED_KEY);
286 expect(yield* objectOf(buckets.delete, SEED_KEY)).toEqual(
287 undefined,
288 );
289
290 const missing = yield* callProbe(baseUrl, "deleteObjectMissing");
291 expect(missing.ok ? undefined : missing.error._tag).toEqual(
292 "NotFound",
293 );
294
295 expect(yield* bucketRoles(buckets.delete)).toEqual([
296 "roles/storage.objectUser",
297 ]);
298 }),
299 {
300 tags: ["provider:gcp", "provider:gcp:storage", "live"],
301 timeout: 600_000,
302 },
303 );
304 });
305
306 describe("SignGetObjectUrl", () => {
307 test.provider(
308 "mints a V4 URL that downloads without credentials; grants objectViewer on the bucket and tokenCreator on the host's own account",
309 (_stack) =>
310 Effect.gen(function* () {
311 const { url } = yield* expectProbe<{ url: string }>(
312 baseUrl,
313 "signGetObjectUrl",
314 );
315 expect(url).toContain(
316 `https://storage.googleapis.com/${buckets.sign}/seed/hello.txt?`,
317 );
318 expect(url).toContain(
319 `X-Goog-Credential=${encodeURIComponent(hostAccount)}`,
320 );
321
322 // No credentials: the signature alone authorizes the download.
323 // The signer's bucket grant may still be propagating.
324 const body = yield* HttpClient.get(url).pipe(
325 Effect.flatMap((response) =>
326 response.status === 200
327 ? response.text
328 : Effect.fail(new Error(`HTTP ${response.status}`)),
329 ),
330 Effect.retry({
331 schedule: Schedule.spaced("5 seconds"),
332 times: 24,
333 }),
334 );
335 expect(body).toEqual(SEED_TEXT);
336
337 expect(yield* bucketRoles(buckets.sign)).toEqual([
338 "roles/storage.objectViewer",
339 ]);
340 const accountPolicy =
341 yield* iam.getIamPolicyProjectsServiceAccounts({
342 resource: `projects/${project}/serviceAccounts/${hostAccount}`,
343 });
344 expect(
345 (accountPolicy.bindings ?? [])
346 .filter((binding) =>
347 (binding.members ?? []).includes(
348 `serviceAccount:${hostAccount}`,
349 ),
350 )
351 .map((binding) => binding.role),
352 ).toEqual(["roles/iam.serviceAccountTokenCreator"]);
353 }),
354 {
355 tags: ["provider:gcp", "provider:gcp:storage", "live"],
356 timeout: 600_000,
357 },
358 );
359 });
360 },
361);