| 1 | import * as Alchemy from "alchemy"; |
| 2 | import * as GCP from "alchemy/GCP"; |
| 3 | import * as Test from "alchemy/Test/Bun"; |
| 4 | import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1"; |
| 5 | import * as kms from "@distilled.cloud/gcp/cloudkms_v1"; |
| 6 | import * as run from "@distilled.cloud/gcp/run_v2"; |
| 7 | import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1"; |
| 8 | import { describe, expect } from "bun:test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 13 | import * as HttpClient from "effect/http/HttpClient"; |
| 14 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 15 | import { spawnSync } from "node:child_process"; |
| 16 | import Stack from "../alchemy.run.ts"; |
| 17 | |
| 18 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 19 | providers: GCP.providers(), |
| 20 | state: Alchemy.localState(), |
| 21 | }); |
| 22 | |
| 23 | const { 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. |
| 27 | const 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. |
| 34 | const dockerAvailable = |
| 35 | spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === |
| 36 | 0; |
| 37 | |
| 38 | const API_KEY = "integ-test-key"; |
| 39 | |
| 40 | // Deploy, tests and destroy all sit behind the same Docker guard. |
| 41 | describe.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 | }); |