1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import { KEY_RING_ID, kmsTestId } from "./common.ts";
4import * as kms from "@distilled.cloud/gcp/cloudkms_v1";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import { GcpEnvironment } from "@/GCP/Environment";
9
10const { test } = Test.make({ providers: GCP.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// Cloud KMS KeyRings cannot be deleted. Reuse the standing test ring.
18// Encrypt/decrypt needs a version; versions cannot be deleted for ≥24h, so
19// this key is reused across runs (names cannot be reused after delete).
20const ENCRYPT_KEY_ID = kmsTestId("cryptokey");
21
22test.provider(
23 "getProjectsLocationsKeyRingsCryptoKeys on a missing key fails with NotFound",
24 () =>
25 Effect.gen(function* () {
26 const { project } = yield* GcpEnvironment.current;
27 const error = yield* Effect.flip(
28 kms.getProjectsLocationsKeyRingsCryptoKeys({
29 name: `projects/${project}/locations/us-central1/keyRings/${KEY_RING_ID}/cryptoKeys/alchemy-cryptokey-does-not-exist`,
30 }),
31 );
32 expect(error._tag).toBe("NotFound");
33 }).pipe(logLevel),
34 { tags: ["provider:gcp", "provider:gcp:kms", "live"] },
35);
36
37test.provider(
38 "create, update, and release a crypto key",
39 (stack) =>
40 Effect.gen(function* () {
41 const { project } = yield* GcpEnvironment.current;
42 yield* stack.destroy();
43
44 const created = yield* stack.deploy(
45 Effect.gen(function* () {
46 const ring = yield* GCP.KMS.KeyRing("Keys", {
47 keyRingId: KEY_RING_ID,
48 location: "us-central1",
49 });
50 return yield* GCP.KMS.CryptoKey("Data", {
51 keyRing: ring.name,
52 skipInitialVersionCreation: true,
53 labels: { env: "test" },
54 });
55 }),
56 );
57
58 expect(created.cryptoKeyId).toEqual(expect.any(String));
59 expect(created.location).toEqual("us-central1");
60 expect(created.project).toEqual(project);
61 expect(created.purpose).toEqual("ENCRYPT_DECRYPT");
62 expect(created.keyRing).toContain(`/keyRings/${KEY_RING_ID}`);
63 expect(created.name).toContain("/cryptoKeys/");
64 expect(created.labels).toMatchObject({ env: "test" });
65 expect(created.primaryVersion).toBeUndefined();
66
67 const fetched = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
68 name: created.name,
69 });
70 expect(fetched.name).toEqual(created.name);
71 expect(fetched.labels?.env).toEqual("test");
72 expect(fetched.purpose).toEqual("ENCRYPT_DECRYPT");
73
74 const updated = yield* stack.deploy(
75 Effect.gen(function* () {
76 const ring = yield* GCP.KMS.KeyRing("Keys", {
77 keyRingId: KEY_RING_ID,
78 location: "us-central1",
79 });
80 return yield* GCP.KMS.CryptoKey("Data", {
81 keyRing: ring.name,
82 cryptoKeyId: created.cryptoKeyId,
83 skipInitialVersionCreation: true,
84 labels: { env: "prod", role: "cmek" },
85 });
86 }),
87 );
88
89 expect(updated.name).toEqual(created.name);
90 expect(updated.labels).toMatchObject({ env: "prod", role: "cmek" });
91
92 const fetchedUpdate = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
93 name: created.name,
94 });
95 expect(fetchedUpdate.labels?.env).toEqual("prod");
96 expect(fetchedUpdate.labels?.role).toEqual("cmek");
97
98 yield* stack.destroy();
99
100 // Deleting would retire the name forever; destroy releases instead.
101 const released = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
102 name: created.name,
103 });
104 expect(released.labels?.["alchemy-released"]).toEqual("true");
105 expect(released.labels?.["alchemy-id"]).toBeUndefined();
106 }).pipe(logLevel),
107 { tags: ["provider:gcp", "provider:gcp:kms", "live"], timeout: 90_000 },
108);
109
110const roundTrip = (name: string, text: string) =>
111 Effect.gen(function* () {
112 const plaintext = yield* Effect.sync(() =>
113 Buffer.from(text, "utf8").toString("base64"),
114 );
115 const { ciphertext } =
116 yield* kms.encryptProjectsLocationsKeyRingsCryptoKeys({
117 name,
118 body: { plaintext },
119 });
120 const decrypted = yield* kms.decryptProjectsLocationsKeyRingsCryptoKeys({
121 name,
122 body: { ciphertext },
123 });
124 return decrypted.plaintext === plaintext;
125 });
126
127test.provider(
128 "destroy releases a fixed-id key and the next deploy reclaims it",
129 (stack) =>
130 Effect.gen(function* () {
131 yield* stack.destroy();
132
133 const deploy = stack.deploy(
134 Effect.gen(function* () {
135 const ring = yield* GCP.KMS.KeyRing("Keys", {
136 keyRingId: KEY_RING_ID,
137 location: "us-central1",
138 });
139 return yield* GCP.KMS.CryptoKey("Cipher", {
140 keyRing: ring.name,
141 cryptoKeyId: ENCRYPT_KEY_ID,
142 labels: { env: "test" },
143 });
144 }),
145 );
146
147 const key = yield* deploy;
148 expect(key.name).toContain(`/cryptoKeys/${ENCRYPT_KEY_ID}`);
149 expect(yield* roundTrip(key.name, "first")).toEqual(true);
150 const firstPrimary = (yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
151 name: key.name,
152 })).primary?.name;
153
154 yield* stack.destroy();
155
156 // KMS keeps the key; Alchemy releases it: every version scheduled for
157 // destruction, ownership labels swapped for the released marker.
158 const released = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
159 name: key.name,
160 });
161 expect(released.labels).toEqual({
162 env: "test",
163 "alchemy-released": "true",
164 });
165 expect(released.primary?.state).toEqual("DESTROY_SCHEDULED");
166
167 // Redeploying the same id reclaims it with fresh key material.
168 const reclaimed = yield* deploy;
169 expect(reclaimed.name).toEqual(key.name);
170 const live = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
171 name: key.name,
172 });
173 expect(live.labels?.["alchemy-released"]).toBeUndefined();
174 expect(live.labels?.["alchemy-id"]).toEqual("cipher");
175 expect(live.primary?.state).toEqual("ENABLED");
176 expect(live.primary?.name).not.toEqual(firstPrimary);
177 expect(yield* roundTrip(key.name, "second")).toEqual(true);
178
179 yield* stack.destroy();
180 }).pipe(logLevel),
181 { tags: ["provider:gcp", "provider:gcp:kms", "live"], timeout: 120_000 },
182);
183
184test.provider(
185 "redeploy after destroy reuses the default ring and key ids",
186 (stack) =>
187 Effect.gen(function* () {
188 const { project } = yield* GcpEnvironment.current;
189 yield* stack.destroy();
190
191 const deploy = stack.deploy(
192 Effect.gen(function* () {
193 const ring = yield* GCP.KMS.KeyRing("RedeployKeys", {
194 location: "us-central1",
195 });
196 const key = yield* GCP.KMS.CryptoKey("RedeployData", {
197 keyRing: ring.name,
198 });
199 return { ring, key };
200 }),
201 );
202
203 // Only this test creates rings with generated ids in this file.
204 const generatedRings = kms
205 .listProjectsLocationsKeyRings({
206 parent: `projects/${project}/locations/us-central1`,
207 pageSize: 1000,
208 })
209 .pipe(
210 Effect.map((response) =>
211 (response.keyRings ?? [])
212 .map((ring) => ring.name ?? "")
213 .filter((name) => name.includes("/keyRings/gcp-kms-cryptokey-")),
214 ),
215 );
216 const before = new Set(yield* generatedRings);
217
218 const first = yield* deploy;
219 expect(yield* roundTrip(first.key.name, "first")).toEqual(true);
220
221 yield* stack.destroy();
222
223 const second = yield* deploy;
224 expect(second.ring.name).toEqual(first.ring.name);
225 expect(second.key.name).toEqual(first.key.name);
226 expect(yield* roundTrip(second.key.name, "second")).toEqual(true);
227
228 // At most the one deterministic ring appeared — no leak per cycle.
229 const created = (yield* generatedRings).filter(
230 (name) => !before.has(name),
231 );
232 expect(created.filter((name) => name !== first.ring.name)).toEqual([]);
233
234 yield* stack.destroy();
235
236 const released = yield* kms.getProjectsLocationsKeyRingsCryptoKeys({
237 name: first.key.name,
238 });
239 expect(released.labels?.["alchemy-released"]).toEqual("true");
240 }).pipe(logLevel),
241 { tags: ["provider:gcp", "provider:gcp:kms", "live"], timeout: 180_000 },
242);