1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { dockerAvailable, expectProbe } from "../bindingHost.ts";
8import SecretManagerBindingsHost, {
9 OpsSecret,
10 ReadOnlySecret,
11 ReadWriteOnlySecret,
12 RegionalSecret,
13 SEEDED,
14 WriteOnlySecret,
15} from "./fixtures/bindings-host.ts";
16
17const testOptions = { providers: GCP.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "SecretManagerBindings");
20
21let baseUrl: string;
22let hostAccount: string;
23let secrets: {
24 ops: string;
25 regional: string;
26 readOnly: string;
27 writeOnly: string;
28 readWrite: string;
29};
30
31const hostRoles = (policy: secretmanager.Policy) =>
32 (policy.bindings ?? [])
33 .filter((binding) =>
34 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
35 )
36 .map((binding) => binding.role)
37 .sort();
38
39/** Roles the host's service account holds on a global secret. */
40const secretRoles = (name: string) =>
41 secretmanager
42 .getIamPolicyProjectsSecrets({ resource: name })
43 .pipe(Effect.map(hostRoles));
44
45/** Roles the host's service account holds on a regional secret. */
46const regionalSecretRoles = (name: string) =>
47 secretmanager
48 .getIamPolicyProjectsLocationsSecrets({ resource: name })
49 .pipe(Effect.map(hostRoles));
50
51/** Every version of a secret with its state, read out of band. */
52const versionStates = (parent: string) =>
53 Effect.gen(function* () {
54 const states: Record<string, string | undefined> = {};
55 let pageToken: string | undefined;
56 do {
57 const page = yield* secretmanager.listProjectsSecretsVersions({
58 parent,
59 pageToken,
60 });
61 for (const version of page.versions ?? []) {
62 if (version.name) states[version.name] = version.state;
63 }
64 pageToken = page.nextPageToken;
65 } while (pageToken);
66 return states;
67 });
68
69describe.skipIf(!dockerAvailable)(
70 "SecretManager Bindings",
71 {
72 tags: [
73 "provider:gcp",
74 "provider:gcp:secretmanager",
75 "provider:gcp:run",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* sharedStack.destroy();
83 const out = yield* sharedStack.deploy(
84 Effect.gen(function* () {
85 const host = yield* SecretManagerBindingsHost;
86 return {
87 uri: host.uri,
88 serviceAccount: host.serviceAccount,
89 ops: (yield* OpsSecret).name,
90 regional: (yield* RegionalSecret).name,
91 readOnly: (yield* ReadOnlySecret).name,
92 writeOnly: (yield* WriteOnlySecret).name,
93 readWrite: (yield* ReadWriteOnlySecret).name,
94 };
95 }),
96 );
97 baseUrl = out.uri!;
98 hostAccount = out.serviceAccount!;
99 secrets = {
100 ops: out.ops,
101 regional: out.regional,
102 readOnly: out.readOnly,
103 writeOnly: out.writeOnly,
104 readWrite: out.readWrite,
105 };
106 }),
107 { timeout: 900_000 },
108 );
109
110 afterAll(sharedStack.destroy(), { timeout: 600_000 });
111
112 describe("AddSecretVersion / AccessSecretVersion", () => {
113 test.provider(
114 "round-trip a global secret as the host's service account, granted on the secret only",
115 (_stack) =>
116 Effect.gen(function* () {
117 const out = yield* expectProbe<{
118 version: string;
119 accessedName: string;
120 data: string;
121 payload: string;
122 }>(baseUrl, "ops");
123 expect(out.version.startsWith(`${secrets.ops}/versions/`)).toBe(
124 true,
125 );
126 expect(out.accessedName).toEqual(out.version);
127 expect(out.data).toEqual(out.payload);
128
129 const states = yield* versionStates(secrets.ops);
130 expect(states[out.version]).toEqual("ENABLED");
131
132 expect(yield* secretRoles(secrets.ops)).toEqual([
133 "roles/secretmanager.secretAccessor",
134 "roles/secretmanager.secretVersionAdder",
135 ]);
136 }),
137 {
138 tags: ["provider:gcp", "provider:gcp:secretmanager", "live"],
139 timeout: 600_000,
140 },
141 );
142
143 test.provider(
144 "round-trip a regional secret as the host's service account, granted on the secret only",
145 (_stack) =>
146 Effect.gen(function* () {
147 const out = yield* expectProbe<{
148 version: string;
149 accessedName: string;
150 data: string;
151 payload: string;
152 }>(baseUrl, "opsRegional");
153 expect(
154 out.version.startsWith(`${secrets.regional}/versions/`),
155 ).toBe(true);
156 expect(out.version).toContain("/locations/us-central1/secrets/");
157 expect(out.accessedName).toEqual(out.version);
158 expect(out.data).toEqual(out.payload);
159
160 expect(yield* regionalSecretRoles(secrets.regional)).toEqual([
161 "roles/secretmanager.secretAccessor",
162 "roles/secretmanager.secretVersionAdder",
163 ]);
164 }),
165 {
166 tags: ["provider:gcp", "provider:gcp:secretmanager", "live"],
167 timeout: 600_000,
168 },
169 );
170 });
171
172 describe("ReadSecret", () => {
173 test.provider(
174 "reads a seeded version as the host's service account, granted secretAccessor on the secret only",
175 (_stack) =>
176 Effect.gen(function* () {
177 yield* secretmanager.addVersionProjectsSecrets({
178 parent: secrets.readOnly,
179 body: {
180 payload: { data: btoa(SEEDED) },
181 },
182 });
183 const out = yield* expectProbe<{
184 latest: string;
185 bytes: string;
186 missing: boolean;
187 }>(baseUrl, "read");
188 expect(out).toEqual({
189 latest: SEEDED,
190 bytes: SEEDED,
191 missing: true,
192 });
193
194 expect(yield* secretRoles(secrets.readOnly)).toEqual([
195 "roles/secretmanager.secretAccessor",
196 ]);
197 }),
198 {
199 tags: ["provider:gcp", "provider:gcp:secretmanager", "live"],
200 timeout: 600_000,
201 },
202 );
203 });
204
205 describe("WriteSecret", () => {
206 test.provider(
207 "adds, disables and destroys versions as the host's service account, granted secretVersionManager on the secret only",
208 (_stack) =>
209 Effect.gen(function* () {
210 const out = yield* expectProbe<{ v1: string; v2: string }>(
211 baseUrl,
212 "write",
213 );
214 expect(out.v1.startsWith(`${secrets.writeOnly}/versions/`)).toBe(
215 true,
216 );
217 expect(out.v2.startsWith(`${secrets.writeOnly}/versions/`)).toBe(
218 true,
219 );
220
221 const states = yield* versionStates(secrets.writeOnly);
222 expect(states[out.v1]).toEqual("DESTROYED");
223 expect(states[out.v2]).toEqual("DISABLED");
224
225 expect(yield* secretRoles(secrets.writeOnly)).toEqual([
226 "roles/secretmanager.secretVersionManager",
227 ]);
228 }),
229 {
230 tags: ["provider:gcp", "provider:gcp:secretmanager", "live"],
231 timeout: 600_000,
232 },
233 );
234 });
235
236 describe("ReadWriteSecret", () => {
237 test.provider(
238 "rotates and reads back as the host's service account, granted accessor + versionManager on the secret only",
239 (_stack) =>
240 Effect.gen(function* () {
241 const out = yield* expectProbe<{
242 v1: string;
243 v2: string;
244 latest: string;
245 first: string;
246 disabledReadsUndefined: boolean;
247 destroyedReadsUndefined: boolean;
248 }>(baseUrl, "readWrite");
249 expect(out).toEqual({
250 v1: expect.stringContaining(`${secrets.readWrite}/versions/`),
251 v2: expect.stringContaining(`${secrets.readWrite}/versions/`),
252 latest: "two",
253 first: "one",
254 disabledReadsUndefined: true,
255 destroyedReadsUndefined: true,
256 });
257
258 const states = yield* versionStates(secrets.readWrite);
259 expect(states[out.v1]).toEqual("DESTROYED");
260 expect(states[out.v2]).toEqual("DISABLED");
261
262 expect(yield* secretRoles(secrets.readWrite)).toEqual([
263 "roles/secretmanager.secretAccessor",
264 "roles/secretmanager.secretVersionManager",
265 ]);
266 }),
267 {
268 tags: ["provider:gcp", "provider:gcp:secretmanager", "live"],
269 timeout: 600_000,
270 },
271 );
272 });
273 },
274);