1import * as storage from "@distilled.cloud/gcp/storage_v1";
2import * as Alchemy from "alchemy";
3import * as GCP from "alchemy/GCP";
4import * as Test from "alchemy/Test/Bun";
5import { expect } from "bun:test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schedule from "effect/Schedule";
9import * as FetchHttpClient from "effect/http/FetchHttpClient";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import Stack from "../alchemy.run.ts";
13
14const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
15 providers: GCP.providers(),
16 state: Alchemy.localState(),
17});
18
19// Out-of-band calls to the Google APIs resolve the same stored credentials
20// the deploy uses, so the test runs against the configured profile.
21const GcpHttp = Layer.mergeAll(
22 GCP.GcpAuth,
23 GCP.fromAuthProvider(),
24 FetchHttpClient.layer,
25);
26
27// Kept for the post-destroy check in `afterAll`.
28let deployedBucket: string | undefined;
29
30const stack = beforeAll(
31 Effect.gen(function* () {
32 const outputs = yield* deploy(Stack);
33 deployedBucket = outputs.bucketName;
34 return outputs;
35 }),
36 { timeout: 300_000 },
37);
38
39afterAll.skipIf(!!process.env.NO_DESTROY)(
40 Effect.gen(function* () {
41 yield* destroy(Stack);
42 if (deployedBucket === undefined) return;
43 // Destroy empties and deletes the bucket (its public grant goes with it).
44 const bucket = yield* storage.getBuckets({ bucket: deployedBucket }).pipe(
45 Effect.as("found" as const),
46 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
47 );
48 expect(bucket).toEqual("gone");
49 }).pipe(Effect.provide(GcpHttp)),
50 { timeout: 300_000 },
51);
52
53// Anonymous GET — no credentials, exactly what a browser sends.
54const get = (url: string) => HttpClient.execute(HttpClientRequest.get(url));
55
56// The `allUsers` grant can take a little while to apply to fresh buckets;
57// until then anonymous reads answer 401/403.
58const getPublic = (url: string) =>
59 get(url).pipe(
60 Effect.repeat({
61 schedule: Schedule.spaced("5 seconds"),
62 until: (response) => response.status !== 401 && response.status !== 403,
63 times: 24,
64 }),
65 );
66
67const siteBase = (bucketName: string) =>
68 `https://storage.googleapis.com/${bucketName}`;
69
70test(
71 "serves index.html publicly with its content type",
72 Effect.gen(function* () {
73 const { url, fileCount } = yield* stack;
74 expect(fileCount).toEqual(4);
75
76 const res = yield* getPublic(url);
77 expect(res.status).toBe(200);
78 expect(res.headers["content-type"]).toEqual("text/html; charset=utf-8");
79 expect(res.headers["cache-control"]).toEqual("no-cache");
80 expect(yield* res.text).toContain("GCP static site on Cloud Storage");
81 }),
82 { timeout: 180_000 },
83);
84
85test(
86 "serves nested pages and assets with correct content types",
87 Effect.gen(function* () {
88 const { bucketName } = yield* stack;
89 const base = siteBase(bucketName);
90
91 const css = yield* getPublic(`${base}/styles.css`);
92 expect(css.status).toBe(200);
93 expect(css.headers["content-type"]).toEqual("text/css; charset=utf-8");
94 expect(css.headers["cache-control"]).toEqual("public, max-age=300");
95 expect(yield* css.text).toContain(".page");
96
97 const docs = yield* getPublic(`${base}/docs/index.html`);
98 expect(docs.status).toBe(200);
99 expect(docs.headers["content-type"]).toEqual("text/html; charset=utf-8");
100 expect(yield* docs.text).toContain("How it works");
101
102 const notFoundPage = yield* getPublic(`${base}/404.html`);
103 expect(notFoundPage.status).toBe(200);
104 expect(yield* notFoundPage.text).toContain("Page not found");
105 }),
106 { timeout: 180_000 },
107);
108
109test(
110 "a missing path is a 404 and the website config is set",
111 Effect.gen(function* () {
112 const { bucketName } = yield* stack;
113 // Path-style URLs answer a missing object with Cloud Storage's own
114 // 404; the `notFoundPage` body is served only through a CNAME or a
115 // load balancer, so it is asserted on the bucket config instead.
116 const missing = yield* getPublic(`${siteBase(bucketName)}/nope.html`);
117 expect(missing.status).toBe(404);
118
119 const bucket = yield* storage
120 .getBuckets({ bucket: bucketName })
121 .pipe(Effect.provide(GcpHttp));
122 expect(bucket.website).toEqual({
123 mainPageSuffix: "index.html",
124 notFoundPage: "404.html",
125 });
126
127 const policy = yield* storage
128 .getIamPolicyBuckets({ bucket: bucketName })
129 .pipe(Effect.provide(GcpHttp));
130 const viewers = policy.bindings?.find(
131 (binding) => binding.role === "roles/storage.objectViewer",
132 );
133 expect(viewers?.members).toContain("allUsers");
134 }),
135 { timeout: 180_000 },
136);