1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
5import * as kms from "@distilled.cloud/gcp/cloudkms_v1";
6import * as run from "@distilled.cloud/gcp/run_v2";
7import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
8import { describe, expect } from "bun:test";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Schedule from "effect/Schedule";
12import * as FetchHttpClient from "effect/http/FetchHttpClient";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import { spawnSync } from "node:child_process";
16import Stack from "../alchemy.run.ts";
17
18const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
19 providers: GCP.providers(),
20 state: Alchemy.localState(),
21});
22
23const { getWhenReady } = Test;
24
25// Out-of-band calls to the Google APIs resolve the same stored credentials
26// the deploy uses, so the test runs against the configured profile.
27const GcpHttp = Layer.mergeAll(
28 GCP.GcpAuth,
29 GCP.fromAuthProvider(),
30 FetchHttpClient.layer,
31);
32
33// The service is built from `main`, which needs a local image build.
34const dockerAvailable =
35 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status ===
36 0;
37
38const API_KEY = "integ-test-key";
39
40// Deploy, tests and destroy all sit behind the same Docker guard.
41describe.skipIf(!dockerAvailable)("gcp-secrets-kms", () => {
42 const stack = beforeAll(
43 Effect.gen(function* () {
44 const outputs = yield* deploy(Stack);
45 // The secret is created empty on purpose — the value is an operator
46 // step. Add the version the service will read.
47 yield* secretmanager
48 .addVersionProjectsSecrets({
49 parent: outputs.secretName,
50 body: { payload: { data: btoa(API_KEY) } },
51 })
52 .pipe(Effect.orDie, Effect.provide(GcpHttp));
53 return outputs;
54 }),
55 { timeout: 900_000 },
56 );
57
58 afterAll.skipIf(!!process.env.NO_DESTROY)(
59 Effect.gen(function* () {
60 const outputs = yield* stack;
61 yield* destroy(Stack);
62 if (outputs === undefined) return;
63
64 // KMS cannot delete a key for at least a day: destroy releases it.
65 const key = yield* kms
66 .getProjectsLocationsKeyRingsCryptoKeys({ name: outputs.cryptoKeyName })
67 .pipe(Effect.orDie, Effect.provide(GcpHttp));
68 expect(Object.keys(key.labels ?? {})).toContain("alchemy-released");
69 // Released keys hold no usable key material.
70 expect(key.primary?.state).not.toBe("ENABLED");
71
72 const secret = yield* secretmanager
73 .getProjectsSecrets({ name: outputs.secretName })
74 .pipe(
75 Effect.map(() => "present" as const),
76 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
77 Effect.orDie,
78 Effect.provide(GcpHttp),
79 );
80 expect(secret).toBe("gone");
81
82 const service = yield* run
83 .getProjectsLocationsServices({ name: outputs.serviceName })
84 .pipe(
85 Effect.map(() => "present" as const),
86 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
87 Effect.orDie,
88 Effect.provide(GcpHttp),
89 );
90 expect(service).toBe("gone");
91
92 // The image repository the service was built into goes with it. Its
93 // id is `{serviceId}-src`, cut to Cloud Run's 49-character limit.
94 const serviceId = outputs.serviceName.split("/").pop()!;
95 const repositoryId = `${serviceId}-src`.slice(0, 49).replace(/-+$/, "");
96 const repository = yield* artifactregistry
97 .getProjectsLocationsRepositories({
98 name: outputs.serviceName.replace(
99 /services\/.*$/,
100 `repositories/${repositoryId}`,
101 ),
102 })
103 .pipe(
104 Effect.map(() => "present" as const),
105 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
106 Effect.orDie,
107 Effect.provide(GcpHttp),
108 );
109 expect(repository).toBe("gone");
110 }),
111 { timeout: 600_000 },
112 );
113
114 const baseUrlOf = (url: string | undefined) => {
115 if (url === undefined) throw new Error("the service has no URL");
116 return url.replace(/\/+$/, "");
117 };
118
119 /** POST JSON, sending `key` as `x-api-key` unless it is `null`. */
120 const post = (url: string, body: unknown, key: string | null = API_KEY) =>
121 HttpClient.execute(
122 HttpClientRequest.post(url).pipe(
123 key === null
124 ? (request) => request
125 : HttpClientRequest.setHeader("x-api-key", key),
126 HttpClientRequest.bodyJsonUnsafe(body),
127 ),
128 );
129
130 test(
131 "rejects a missing or wrong API key with 401",
132 Effect.gen(function* () {
133 const { url } = yield* stack;
134 expect(url).toMatch(/^https:\/\//);
135 const baseUrl = baseUrlOf(url);
136 yield* getWhenReady(`${baseUrl}/`);
137
138 const missing = yield* post(
139 `${baseUrl}/encrypt`,
140 { plaintext: "hi" },
141 null,
142 );
143 expect(missing.status).toBe(401);
144
145 const wrong = yield* post(
146 `${baseUrl}/encrypt`,
147 { plaintext: "hi" },
148 "no",
149 );
150 expect(wrong.status).toBe(401);
151
152 const wrongDecrypt = yield* post(
153 `${baseUrl}/decrypt`,
154 { ciphertext: "AAAA" },
155 "integ-test-kez",
156 );
157 expect(wrongDecrypt.status).toBe(401);
158 }),
159 { timeout: 120_000 },
160 );
161
162 test(
163 "encrypts with KMS and decrypts back, in and out of band",
164 Effect.gen(function* () {
165 const { url, cryptoKeyName } = yield* stack;
166 const baseUrl = baseUrlOf(url);
167 yield* getWhenReady(`${baseUrl}/`);
168
169 const plaintext = "the eagle lands at midnight";
170
171 // The KMS grants on a fresh service account can take a minute or two
172 // to propagate; until then encrypt fails with Forbidden (500).
173 const encrypted = yield* post(`${baseUrl}/encrypt`, { plaintext }).pipe(
174 Effect.repeat({
175 schedule: Schedule.spaced("10 seconds"),
176 until: (response) => response.status !== 500,
177 times: 30,
178 }),
179 );
180 expect(encrypted.status).toBe(200);
181 const { ciphertext } = (yield* encrypted.json) as { ciphertext: string };
182 expect(ciphertext).toEqual(expect.any(String));
183 expect(ciphertext).not.toEqual(plaintext);
184 expect(ciphertext).not.toEqual(btoa(plaintext));
185 expect(atob(ciphertext)).not.toContain(plaintext);
186
187 const decrypted = yield* post(`${baseUrl}/decrypt`, { ciphertext }).pipe(
188 Effect.repeat({
189 schedule: Schedule.spaced("10 seconds"),
190 until: (response) => response.status !== 500,
191 times: 30,
192 }),
193 );
194 expect(decrypted.status).toBe(200);
195 expect((yield* decrypted.json) as { plaintext: string }).toEqual({
196 plaintext,
197 });
198
199 // The ciphertext is real KMS output: decrypt it directly with the key.
200 const direct = yield* kms
201 .decryptProjectsLocationsKeyRingsCryptoKeys({
202 name: cryptoKeyName,
203 body: { ciphertext },
204 })
205 .pipe(Effect.orDie, Effect.provide(GcpHttp));
206 expect(
207 Buffer.from(direct.plaintext ?? "", "base64").toString("utf8"),
208 ).toEqual(plaintext);
209
210 // Garbage ciphertext is the caller's error, not a server failure.
211 const garbage = yield* post(`${baseUrl}/decrypt`, {
212 ciphertext: btoa("not a kms ciphertext"),
213 });
214 expect(garbage.status).toBe(400);
215 }),
216 { timeout: 600_000 },
217 );
218});