1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as cloudrun from "@distilled.cloud/gcp/run_v2";
5import { describe, 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 { spawnSync } from "node:child_process";
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
19const { getWhenReady } = Test;
20
21// Out-of-band calls to the Google APIs resolve the same stored credentials
22// the deploy uses, so the test runs against the configured profile.
23const GcpHttp = Layer.mergeAll(
24 GCP.GcpAuth,
25 GCP.fromAuthProvider(),
26 FetchHttpClient.layer,
27);
28
29// Both services are built from `main`, which needs a local image build.
30const dockerAvailable =
31 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status ===
32 0;
33
34// Deploy, tests and destroy all sit behind the same Docker guard.
35describe.skipIf(!dockerAvailable)("gcp-service-to-service", () => {
36 const stack = beforeAll(deploy(Stack), { timeout: 900_000 });
37
38 const serviceState = (name: string) =>
39 cloudrun.getProjectsLocationsServices({ name }).pipe(
40 Effect.as("found" as const),
41 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
42 Effect.orDie,
43 Effect.provide(GcpHttp),
44 );
45
46 afterAll.skipIf(!!process.env.NO_DESTROY)(
47 Effect.gen(function* () {
48 const { gatewayName, quotesName } = yield* stack;
49 yield* destroy(Stack);
50 // Nothing is left behind: both Cloud Run services are gone.
51 expect(yield* serviceState(gatewayName)).toEqual("gone");
52 expect(yield* serviceState(quotesName)).toEqual("gone");
53 }),
54 { timeout: 600_000 },
55 );
56
57 const baseUrlOf = (url: string | undefined) => {
58 if (url === undefined) throw new Error("the service has no URL");
59 return url.replace(/\/+$/, "");
60 };
61
62 const status = (url: string) =>
63 HttpClient.get(url).pipe(Effect.map((response) => response.status));
64
65 test(
66 "the gateway is public",
67 Effect.gen(function* () {
68 const { url } = yield* stack;
69 expect(url).toMatch(/^https:\/\//);
70 const res = yield* getWhenReady(`${baseUrlOf(url)}/`);
71 expect(res.status).toBe(200);
72 }),
73 { timeout: 120_000 },
74 );
75
76 test(
77 "the quotes service rejects callers without a Google identity",
78 Effect.gen(function* () {
79 const { quotesUrl } = yield* stack;
80 const code = yield* status(`${baseUrlOf(quotesUrl)}/quote`).pipe(
81 Effect.repeat({
82 schedule: Schedule.spaced("5 seconds"),
83 until: (code) => code === 403,
84 times: 12,
85 }),
86 );
87 expect(code).toBe(403);
88 }),
89 { timeout: 120_000 },
90 );
91
92 test(
93 "GET /quote on the gateway is served by the private quotes service",
94 Effect.gen(function* () {
95 const { url, quotesName, gatewayServiceAccount } = yield* stack;
96 const baseUrl = baseUrlOf(url);
97 yield* getWhenReady(`${baseUrl}/`);
98
99 // run.invoker lands on the quotes service's own policy, for the
100 // gateway's runtime service account only.
101 const policy = yield* cloudrun
102 .getIamPolicyProjectsLocationsServices({ resource: quotesName })
103 .pipe(Effect.orDie, Effect.provide(GcpHttp));
104 const invokers =
105 policy.bindings?.find((binding) => binding.role === "roles/run.invoker")
106 ?.members ?? [];
107 expect(invokers).toEqual([`serviceAccount:${gatewayServiceAccount}`]);
108
109 // A fresh run.invoker grant can take a moment to reach Cloud Run's
110 // front end; until then the gateway relays the 403.
111 const response = yield* HttpClient.get(`${baseUrl}/quote?n=1`).pipe(
112 Effect.repeat({
113 schedule: Schedule.spaced("5 seconds"),
114 until: (response) => response.status !== 403,
115 times: 24,
116 }),
117 );
118 expect(response.status).toBe(200);
119 expect(yield* response.json).toEqual({
120 index: 1,
121 quote: "Make it work, make it right, make it fast. — Kent Beck",
122 servedBy: "quotes",
123 });
124 }),
125 { timeout: 180_000 },
126 );
127});