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 * as Schedule from "effect/Schedule";
9import { GcpEnvironment } from "@/GCP/Environment";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// Cloud KMS KeyRings cannot be deleted. Reuse the standing test ring.
19// Versions enter DESTROY_SCHEDULED for ≥24h, so reuse a standing key.
20const CRYPTO_KEY_ID = kmsTestId("version");
21
22const waitUntilGone = (name: string) =>
23 kms.getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }).pipe(
24 Effect.as("found" as const),
25 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
26 Effect.repeat({
27 schedule: Schedule.spaced("1 second"),
28 until: (status) => status === "gone",
29 times: 10,
30 }),
31 );
32
33const waitVersionReady = (name: string) =>
34 kms.getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }).pipe(
35 Effect.repeat({
36 schedule: Schedule.spaced("500 millis"),
37 until: (version) =>
38 version.state === "ENABLED" || version.state === "DISABLED",
39 times: 10,
40 }),
41 );
42
43test.provider(
44 "getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions on a missing version fails with NotFound",
45 () =>
46 Effect.gen(function* () {
47 const { project } = yield* GcpEnvironment.current;
48 const error = yield* Effect.flip(
49 kms.getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
50 name: `projects/${project}/locations/us-central1/keyRings/${KEY_RING_ID}/cryptoKeys/${CRYPTO_KEY_ID}/cryptoKeyVersions/999999`,
51 }),
52 );
53 expect(error._tag).toBe("NotFound");
54 }).pipe(logLevel),
55 { tags: ["provider:gcp", "provider:gcp:kms", "live"] },
56);
57
58test.provider(
59 "create, update, and destroy a crypto key version",
60 (stack) =>
61 Effect.gen(function* () {
62 const { project } = yield* GcpEnvironment.current;
63 yield* stack.destroy();
64
65 const created = yield* stack.deploy(
66 Effect.gen(function* () {
67 const ring = yield* GCP.KMS.KeyRing("Keys", {
68 keyRingId: KEY_RING_ID,
69 location: "us-central1",
70 });
71 const key = yield* GCP.KMS.CryptoKey("Data", {
72 keyRing: ring.name,
73 cryptoKeyId: CRYPTO_KEY_ID,
74 skipInitialVersionCreation: true,
75 labels: { env: "test" },
76 });
77 return yield* GCP.KMS.CryptoKeyVersion("V1", {
78 cryptoKey: key.name,
79 });
80 }),
81 );
82
83 expect(created.cryptoKeyVersionId).toEqual(expect.any(String));
84 expect(created.location).toEqual("us-central1");
85 expect(created.project).toEqual(project);
86 expect(created.cryptoKey).toContain(`/cryptoKeys/${CRYPTO_KEY_ID}`);
87 expect(created.name).toContain("/cryptoKeyVersions/");
88 expect(["ENABLED", "PENDING_GENERATION"]).toContain(created.state);
89
90 yield* waitVersionReady(created.name);
91
92 const fetched =
93 yield* kms.getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
94 name: created.name,
95 });
96 expect(fetched.name).toEqual(created.name);
97 expect(fetched.state).toEqual("ENABLED");
98
99 const disabled = yield* stack.deploy(
100 Effect.gen(function* () {
101 const ring = yield* GCP.KMS.KeyRing("Keys", {
102 keyRingId: KEY_RING_ID,
103 location: "us-central1",
104 });
105 const key = yield* GCP.KMS.CryptoKey("Data", {
106 keyRing: ring.name,
107 cryptoKeyId: CRYPTO_KEY_ID,
108 skipInitialVersionCreation: true,
109 labels: { env: "test" },
110 });
111 return yield* GCP.KMS.CryptoKeyVersion("V1", {
112 cryptoKey: key.name,
113 cryptoKeyVersionId: created.cryptoKeyVersionId,
114 state: "DISABLED",
115 });
116 }),
117 );
118 expect(disabled.name).toEqual(created.name);
119 expect(disabled.state).toEqual("DISABLED");
120
121 const fetchedDisabled =
122 yield* kms.getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
123 name: created.name,
124 });
125 expect(fetchedDisabled.state).toEqual("DISABLED");
126
127 const enabled = yield* stack.deploy(
128 Effect.gen(function* () {
129 const ring = yield* GCP.KMS.KeyRing("Keys", {
130 keyRingId: KEY_RING_ID,
131 location: "us-central1",
132 });
133 const key = yield* GCP.KMS.CryptoKey("Data", {
134 keyRing: ring.name,
135 cryptoKeyId: CRYPTO_KEY_ID,
136 skipInitialVersionCreation: true,
137 labels: { env: "test" },
138 });
139 return yield* GCP.KMS.CryptoKeyVersion("V1", {
140 cryptoKey: key.name,
141 cryptoKeyVersionId: created.cryptoKeyVersionId,
142 state: "ENABLED",
143 });
144 }),
145 );
146 expect(enabled.name).toEqual(created.name);
147 expect(enabled.state).toEqual("ENABLED");
148
149 yield* stack.destroy();
150
151 const afterDestroy = yield* kms
152 .getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
153 name: created.name,
154 })
155 .pipe(
156 Effect.map((version) => version.state ?? "found"),
157 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
158 );
159 expect(["DESTROY_SCHEDULED", "DESTROYED", "gone"]).toContain(
160 afterDestroy,
161 );
162
163 const gone = yield* waitUntilGone(
164 `projects/${project}/locations/us-central1/keyRings/${KEY_RING_ID}/cryptoKeys/${CRYPTO_KEY_ID}/cryptoKeyVersions/999999`,
165 );
166 expect(gone).toEqual("gone");
167 }).pipe(logLevel),
168 { tags: ["provider:gcp", "provider:gcp:kms", "live"], timeout: 90_000 },
169);