1import * as GCP from "@/GCP";
2import { decodeFields, encodeFields } from "@/GCP/Firestore/Values.ts";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as crm from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import * as firestore from "@distilled.cloud/gcp/firestore_v1";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { dockerAvailable, expectProbe } from "../bindingHost.ts";
10import FirestoreBindingsHost, {
11 DocsDatabase,
12 PATCHED,
13 ReadOnlyDatabase,
14 ReadWriteOnlyDatabase,
15 SEEDED_DELETE,
16 SEEDED_GET,
17 WriteOnlyDatabase,
18} from "./fixtures/bindings-host.ts";
19
20const testOptions = { providers: GCP.providers() };
21const { test, beforeAll, afterAll } = Test.make(testOptions);
22const sharedStack = Core.scratchStack(testOptions, "FirestoreBindings");
23
24let baseUrl: string;
25let hostAccount: string;
26let project: string;
27let databases: {
28 docs: string;
29 readOnly: string;
30 writeOnly: string;
31 readWrite: string;
32};
33
34/** Write a document out of band, as the deployer. */
35const seed = (
36 database: string,
37 path: string,
38 fields: Record<string, unknown>,
39) =>
40 firestore.patchProjectsDatabasesDocuments({
41 name: `${database}/documents/${path}`,
42 body: { fields: encodeFields(fields) },
43 });
44
45/** Read a document's fields out of band, `undefined` when it is missing. */
46const readOutOfBand = (database: string, path: string) =>
47 firestore
48 .getProjectsDatabasesDocuments({ name: `${database}/documents/${path}` })
49 .pipe(
50 Effect.map((document) => decodeFields(document.fields)),
51 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
52 );
53
54/**
55 * Roles the host holds on the project under the IAM Condition naming only
56 * `database` (Firestore has no per-database IAM policy). Also asserts the
57 * host holds no unconditioned project role.
58 */
59const scopedRoles = (database: string) =>
60 Effect.gen(function* () {
61 const policy = yield* crm.getIamPolicyProjects({
62 resource: `projects/${project}`,
63 body: { options: { requestedPolicyVersion: 3 } },
64 });
65 const hostBindings = (policy.bindings ?? []).filter((binding) =>
66 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
67 );
68 expect(
69 hostBindings.filter((binding) => binding.condition === undefined),
70 ).toEqual([]);
71 const condition = `resource.name == "${database}" || resource.name.startsWith("${database}/")`;
72 return hostBindings
73 .filter((binding) => binding.condition?.expression === condition)
74 .map((binding) => binding.role)
75 .sort();
76 });
77
78describe.skipIf(!dockerAvailable || !!process.env.FAST)(
79 "Firestore Bindings",
80 {
81 tags: [
82 "provider:gcp",
83 "provider:gcp:firestore",
84 "provider:gcp:run",
85 "live",
86 ],
87 },
88 () => {
89 beforeAll(
90 Effect.gen(function* () {
91 yield* sharedStack.destroy();
92 const out = yield* sharedStack.deploy(
93 Effect.gen(function* () {
94 const host = yield* FirestoreBindingsHost;
95 const docs = yield* DocsDatabase;
96 return {
97 uri: host.uri,
98 serviceAccount: host.serviceAccount,
99 project: docs.project,
100 docs: docs.name,
101 readOnly: (yield* ReadOnlyDatabase).name,
102 writeOnly: (yield* WriteOnlyDatabase).name,
103 readWrite: (yield* ReadWriteOnlyDatabase).name,
104 };
105 }),
106 );
107 baseUrl = out.uri!;
108 hostAccount = out.serviceAccount!;
109 project = out.project;
110 databases = {
111 docs: out.docs,
112 readOnly: out.readOnly,
113 writeOnly: out.writeOnly,
114 readWrite: out.readWrite,
115 };
116 }),
117 { timeout: 900_000 },
118 );
119
120 afterAll(sharedStack.destroy(), { timeout: 600_000 });
121
122 const expectDocsGrants = Effect.suspend(() =>
123 scopedRoles(databases.docs),
124 ).pipe(
125 Effect.map((roles) =>
126 expect(roles).toEqual([
127 "roles/datastore.user",
128 "roles/datastore.viewer",
129 ]),
130 ),
131 );
132
133 describe("PatchDocument", () => {
134 test.provider(
135 "writes a document as the host's service account, scoped to the database",
136 (_stack) =>
137 Effect.gen(function* () {
138 const doc = yield* expectProbe<firestore.Document>(
139 baseUrl,
140 "patchDocument",
141 );
142 expect(doc.name).toEqual(`${databases.docs}/documents/${PATCHED}`);
143 expect(yield* readOutOfBand(databases.docs, PATCHED)).toEqual({
144 name: "Alice",
145 });
146 yield* expectDocsGrants;
147 }),
148 {
149 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
150 timeout: 600_000,
151 },
152 );
153 });
154
155 describe("GetDocument", () => {
156 test.provider(
157 "reads a seeded document as the host's service account, scoped to the database",
158 (_stack) =>
159 Effect.gen(function* () {
160 yield* seed(databases.docs, SEEDED_GET, { name: "Seeded" });
161 const doc = yield* expectProbe<firestore.Document>(
162 baseUrl,
163 "getDocument",
164 );
165 expect(doc.fields?.name?.stringValue).toEqual("Seeded");
166 yield* expectDocsGrants;
167 }),
168 {
169 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
170 timeout: 600_000,
171 },
172 );
173 });
174
175 describe("DeleteDocument", () => {
176 test.provider(
177 "deletes a seeded document as the host's service account, scoped to the database",
178 (_stack) =>
179 Effect.gen(function* () {
180 yield* seed(databases.docs, SEEDED_DELETE, { name: "Doomed" });
181 yield* expectProbe(baseUrl, "deleteDocument");
182 expect(
183 yield* readOutOfBand(databases.docs, SEEDED_DELETE),
184 ).toBeUndefined();
185 yield* expectDocsGrants;
186 }),
187 {
188 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
189 timeout: 600_000,
190 },
191 );
192 });
193
194 describe("ReadDatabase", () => {
195 test.provider(
196 "gets, lists and queries seeded documents, granted datastore.viewer under the database condition",
197 (_stack) =>
198 Effect.gen(function* () {
199 yield* seed(databases.readOnly, "things/a", { n: 1, s: "a" });
200 yield* seed(databases.readOnly, "things/b", { n: 2, s: "b" });
201 const out = yield* expectProbe(baseUrl, "read");
202 expect(out).toEqual({
203 one: { n: 1, s: "a" },
204 missing: true,
205 listed: ["a", "b"],
206 hasNextPage: true,
207 big: [2],
208 });
209 expect(yield* scopedRoles(databases.readOnly)).toEqual([
210 "roles/datastore.viewer",
211 ]);
212 }),
213 {
214 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
215 timeout: 600_000,
216 },
217 );
218 });
219
220 describe("WriteDatabase", () => {
221 test.provider(
222 "creates, updates, sets and deletes documents, granted datastore.user under the database condition",
223 (_stack) =>
224 Effect.gen(function* () {
225 const out = yield* expectProbe(baseUrl, "write");
226 expect(out).toEqual({
227 created: true,
228 conflict: "things/created",
229 updated: { n: 1, "odd key": "x" },
230 updateMissing: "NotFound",
231 });
232 expect(
233 yield* readOutOfBand(databases.writeOnly, "things/created"),
234 ).toEqual({ n: 1, "odd key": "x" });
235 expect(
236 yield* readOutOfBand(databases.writeOnly, "things/set"),
237 ).toEqual({ team: "red" });
238 expect(
239 yield* readOutOfBand(databases.writeOnly, "things/gone"),
240 ).toBeUndefined();
241 expect(yield* scopedRoles(databases.writeOnly)).toEqual([
242 "roles/datastore.user",
243 ]);
244 }),
245 {
246 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
247 timeout: 600_000,
248 },
249 );
250 });
251
252 describe("ReadWriteDatabase", () => {
253 test.provider(
254 "round-trips documents, granted datastore.user under the database condition",
255 (_stack) =>
256 Effect.gen(function* () {
257 const out = yield* expectProbe(baseUrl, "readWrite");
258 expect(out).toEqual({
259 missing: true,
260 b: { team: "blue", n: 3 },
261 listed: ["a", "b"],
262 red: ["a"],
263 afterDelete: true,
264 });
265 expect(
266 yield* readOutOfBand(databases.readWrite, "scores/b"),
267 ).toEqual({ team: "blue", n: 3 });
268 expect(
269 yield* readOutOfBand(databases.readWrite, "scores/a"),
270 ).toBeUndefined();
271 expect(yield* scopedRoles(databases.readWrite)).toEqual([
272 "roles/datastore.user",
273 ]);
274 }),
275 {
276 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
277 timeout: 600_000,
278 },
279 );
280 });
281 },
282);