1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import * as redis from "@distilled.cloud/gcp/redis_v1";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { dockerAvailable, expectProbe } from "../bindingHost.ts";
10import RedisBindingsHost, {
11 Acl,
12 Cache,
13 WRITTEN_VALUE,
14 slow,
15} from "./fixtures/bindings-host.ts";
16
17const testOptions = { providers: GCP.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "RedisBindings");
20
21let baseUrl: string;
22let member: string;
23let aclName: string;
24let cacheName: string | undefined;
25
26const scoped = (name: string) =>
27 `resource.name == "${name}" || resource.name.startsWith("${name}/")`;
28
29/**
30 * `[role, condition expression]` the host holds on the project: Memorystore
31 * has no resource-level IAM, so every grant is a project grant narrowed by
32 * an IAM Condition on the bound resource's name.
33 */
34const hostProjectGrants = Effect.gen(function* () {
35 const { project } = yield* GcpEnvironment.current;
36 const policy = yield* resourcemanager.getIamPolicyProjects({
37 resource: `projects/${project}`,
38 body: { options: { requestedPolicyVersion: 3 } },
39 });
40 return (policy.bindings ?? [])
41 .filter((binding) => (binding.members ?? []).includes(member))
42 .map((binding) => [binding.role, binding.condition?.expression]);
43});
44
45describe.skipIf(!dockerAvailable)(
46 "Redis Bindings",
47 { tags: ["provider:gcp", "provider:gcp:redis", "provider:gcp:run", "live"] },
48 () => {
49 beforeAll(
50 Effect.gen(function* () {
51 yield* sharedStack.destroy();
52 const out = yield* sharedStack.deploy(
53 Effect.gen(function* () {
54 const host = yield* RedisBindingsHost;
55 return {
56 uri: host.uri,
57 serviceAccount: host.serviceAccount,
58 acl: (yield* Acl).name,
59 cache: slow ? (yield* Cache).name : undefined,
60 };
61 }),
62 );
63 baseUrl = out.uri!;
64 member = `serviceAccount:${out.serviceAccount!}`;
65 aclName = out.acl;
66 cacheName = out.cache;
67 }),
68 { timeout: 2_400_000 },
69 );
70
71 afterAll(sharedStack.destroy(), { timeout: 1_800_000 });
72
73 describe("GetAclPolicy", () => {
74 test.provider(
75 "reads the ACL policy, granted viewer scoped to it",
76 (_stack) =>
77 Effect.gen(function* () {
78 const live = yield* expectProbe<redis.AclPolicy>(
79 baseUrl,
80 "getAclPolicy",
81 );
82 expect(live.name).toEqual(aclName);
83 expect(live.rules).toEqual([
84 { username: "reader", rule: "on ~cache:* +get" },
85 ]);
86 expect(yield* hostProjectGrants).toContainEqual([
87 "roles/redis.viewer",
88 scoped(aclName),
89 ]);
90 }),
91 {
92 tags: ["provider:gcp", "provider:gcp:redis", "live"],
93 timeout: 600_000,
94 },
95 );
96 });
97
98 // Memorystore instance create + delete runs well past 5 minutes.
99 describe.skipIf(!slow)("GetInstance", () => {
100 test.provider(
101 "reads the instance, granted viewer scoped to it",
102 (_stack) =>
103 Effect.gen(function* () {
104 const live = yield* expectProbe<redis.Instance>(
105 baseUrl,
106 "getInstance",
107 );
108 expect(live.name).toEqual(cacheName);
109 expect(live.authEnabled).toEqual(true);
110 expect(yield* hostProjectGrants).toContainEqual([
111 "roles/redis.viewer",
112 scoped(cacheName!),
113 ]);
114 }),
115 {
116 tags: ["provider:gcp", "provider:gcp:redis", "live"],
117 timeout: 600_000,
118 },
119 );
120 });
121
122 describe.skipIf(!slow)("GetAuthString", () => {
123 test.provider(
124 "reads the AUTH string, granted admin scoped to the instance",
125 (_stack) =>
126 Effect.gen(function* () {
127 const auth = yield* expectProbe<redis.InstanceAuthString>(
128 baseUrl,
129 "getAuthString",
130 );
131 const direct = yield* redis.getAuthStringProjectsLocationsInstances(
132 { name: cacheName! },
133 );
134 expect(auth.authString).toEqual(direct.authString);
135 expect(auth.authString?.length ?? 0).toBeGreaterThan(0);
136 expect(yield* hostProjectGrants).toContainEqual([
137 "roles/redis.admin",
138 scoped(cacheName!),
139 ]);
140 }),
141 {
142 tags: ["provider:gcp", "provider:gcp:redis", "live"],
143 timeout: 600_000,
144 },
145 );
146 });
147
148 describe.skipIf(!slow)("WriteRedis", () => {
149 test.provider(
150 "writes over AUTH, the AUTH lookup granted admin scoped to the instance",
151 (_stack) =>
152 Effect.gen(function* () {
153 const out = yield* expectProbe<{ incremented: number }>(
154 baseUrl,
155 "writeRedis",
156 );
157 expect(out.incremented).toBeGreaterThan(0);
158 expect(yield* hostProjectGrants).toContainEqual([
159 "roles/redis.admin",
160 scoped(cacheName!),
161 ]);
162 }),
163 {
164 tags: ["provider:gcp", "provider:gcp:redis", "live"],
165 timeout: 600_000,
166 },
167 );
168 });
169
170 describe.skipIf(!slow)("ReadRedis", () => {
171 test.provider(
172 "reads what WriteRedis wrote, over AUTH",
173 (_stack) =>
174 Effect.gen(function* () {
175 const out = yield* expectProbe<{
176 value: string | null;
177 exists: number;
178 pong: string;
179 }>(baseUrl, "readRedis");
180 expect(out).toEqual({
181 value: WRITTEN_VALUE,
182 exists: 1,
183 pong: "PONG",
184 });
185 }),
186 {
187 tags: ["provider:gcp", "provider:gcp:redis", "live"],
188 timeout: 600_000,
189 },
190 );
191 });
192
193 describe.skipIf(!slow)("ReadWriteRedis", () => {
194 test.provider(
195 "round-trips a key over AUTH",
196 (_stack) =>
197 Effect.gen(function* () {
198 const out = yield* expectProbe<{
199 value: string | null;
200 deleted: number;
201 }>(baseUrl, "readWriteRedis");
202 expect(out).toEqual({ value: "rw", deleted: 1 });
203
204 const grants = yield* hostProjectGrants;
205 // Every grant is condition-scoped; nothing lands on the whole
206 // project.
207 expect(grants.filter(([, expression]) => !expression)).toEqual([]);
208 }),
209 {
210 tags: ["provider:gcp", "provider:gcp:redis", "live"],
211 timeout: 600_000,
212 },
213 );
214 });
215 },
216);