1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as crm from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as datastore from "@distilled.cloud/gcp/datastore_v1";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { dockerAvailable, expectProbe } from "../bindingHost.ts";
9import DatastoreBindingsHost, {
10 apiDatabaseId,
11 COMMITTED,
12 SEEDED,
13 Tasks,
14} from "./fixtures/bindings-host.ts";
15
16const testOptions = { providers: GCP.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "DatastoreBindings");
19
20let baseUrl: string;
21let hostAccount: string;
22let project: string;
23let databaseName: string;
24let databaseId: string;
25
26const keyOf = (path: { kind: string; name: string }) => ({
27 partitionId: { projectId: project, databaseId },
28 path: [path],
29});
30
31/** Named databases require the routing header. */
32const requestParams = () =>
33 databaseId.length > 0
34 ? `project_id=${project}&database_id=${databaseId}`
35 : undefined;
36
37/** Look an entity up out of band, as the deployer. */
38const lookupOutOfBand = (path: { kind: string; name: string }) =>
39 datastore.lookupProjects({
40 projectId: project,
41 requestParams: requestParams(),
42 body: { databaseId, keys: [keyOf(path)] },
43 });
44
45/** Seed the entity the Lookup / RunQuery probes read, as the deployer. */
46const seed = Effect.suspend(() =>
47 datastore.commitProjects({
48 projectId: project,
49 requestParams: requestParams(),
50 body: {
51 databaseId,
52 mode: "NON_TRANSACTIONAL",
53 mutations: [
54 {
55 upsert: {
56 key: keyOf(SEEDED),
57 properties: { title: { stringValue: "seeded" } },
58 },
59 },
60 ],
61 },
62 }),
63);
64
65/**
66 * The host's project-level grants. Datastore has no per-database IAM
67 * policy, so each binding grants its role on the project under an IAM
68 * Condition matching only the bound database and its children.
69 */
70const projectGrants = Effect.gen(function* () {
71 const policy = yield* crm.getIamPolicyProjects({
72 resource: `projects/${project}`,
73 body: { options: { requestedPolicyVersion: 3 } },
74 });
75 return (policy.bindings ?? [])
76 .filter((binding) =>
77 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
78 )
79 .map((binding) => ({
80 role: binding.role,
81 condition: binding.condition?.expression,
82 }))
83 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? ""));
84});
85
86const expectScopedGrants = Effect.gen(function* () {
87 const condition = `resource.name == "${databaseName}" || resource.name.startsWith("${databaseName}/")`;
88 expect(yield* projectGrants).toEqual([
89 { role: "roles/datastore.user", condition },
90 { role: "roles/datastore.viewer", condition },
91 ]);
92});
93
94describe.skipIf(!dockerAvailable || !!process.env.FAST)(
95 "Datastore Bindings",
96 {
97 tags: [
98 "provider:gcp",
99 "provider:gcp:datastore",
100 "provider:gcp:firestore",
101 "provider:gcp:run",
102 "live",
103 ],
104 },
105 () => {
106 beforeAll(
107 Effect.gen(function* () {
108 yield* sharedStack.destroy();
109 const out = yield* sharedStack.deploy(
110 Effect.gen(function* () {
111 const host = yield* DatastoreBindingsHost;
112 const database = yield* Tasks;
113 return {
114 uri: host.uri,
115 serviceAccount: host.serviceAccount,
116 name: database.name,
117 project: database.project,
118 databaseId: database.databaseId,
119 };
120 }),
121 );
122 baseUrl = out.uri!;
123 hostAccount = out.serviceAccount!;
124 project = out.project;
125 databaseName = out.name;
126 databaseId = apiDatabaseId(out.databaseId);
127 }),
128 { timeout: 900_000 },
129 );
130
131 afterAll(sharedStack.destroy(), { timeout: 600_000 });
132
133 describe("Commit", () => {
134 test.provider(
135 "upserts an entity as the host's service account, scoped to the database",
136 (_stack) =>
137 Effect.gen(function* () {
138 const committed = yield* expectProbe<datastore.CommitResponse>(
139 baseUrl,
140 "commit",
141 );
142 expect(committed.mutationResults?.length).toEqual(1);
143
144 const found = yield* lookupOutOfBand(COMMITTED);
145 expect(
146 found.found?.map(
147 (result) => result.entity?.properties?.title?.stringValue,
148 ),
149 ).toEqual(["committed"]);
150
151 yield* expectScopedGrants;
152 }),
153 {
154 tags: ["provider:gcp", "provider:gcp:datastore", "live"],
155 timeout: 600_000,
156 },
157 );
158 });
159
160 describe("Lookup", () => {
161 test.provider(
162 "reads the seeded entity as the host's service account, scoped to the database",
163 (_stack) =>
164 Effect.gen(function* () {
165 yield* seed;
166 const found = yield* expectProbe<datastore.LookupResponse>(
167 baseUrl,
168 "lookup",
169 );
170 expect(
171 found.found?.map(
172 (result) => result.entity?.properties?.title?.stringValue,
173 ),
174 ).toEqual(["seeded"]);
175 expect(found.missing ?? []).toEqual([]);
176
177 yield* expectScopedGrants;
178 }),
179 {
180 tags: ["provider:gcp", "provider:gcp:datastore", "live"],
181 timeout: 600_000,
182 },
183 );
184 });
185
186 describe("RunQuery", () => {
187 test.provider(
188 "queries the seeded kind as the host's service account, scoped to the database",
189 (_stack) =>
190 Effect.gen(function* () {
191 yield* seed;
192 const page = yield* expectProbe<datastore.RunQueryResponse>(
193 baseUrl,
194 "runQuery",
195 );
196 expect(
197 page.batch?.entityResults?.map(
198 (result) => result.entity?.key?.path?.[0]?.name,
199 ),
200 ).toEqual([SEEDED.name]);
201
202 yield* expectScopedGrants;
203 }),
204 {
205 tags: ["provider:gcp", "provider:gcp:datastore", "live"],
206 timeout: 600_000,
207 },
208 );
209 });
210 },
211);