1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as firestore from "@distilled.cloud/gcp/firestore_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9
10const { test } = Test.make({ providers: GCP.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const runLifecycle = !process.env.FAST;
18const enterpriseDatabaseId = "alchfsucreds2";
19const enterpriseDatabaseNameOf = (project: string) =>
20 `projects/${project}/databases/${enterpriseDatabaseId}`;
21
22const waitUntilGone = (name: string) =>
23 firestore.getProjectsDatabasesUserCreds({ name }).pipe(
24 Effect.as("found" as const),
25 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
26 Effect.repeat({
27 schedule: Schedule.spaced("2 seconds"),
28 until: (status) => status === "gone",
29 times: 10,
30 }),
31 );
32
33const waitUntilDatabase = (name: string, want: "ready" | "gone") =>
34 firestore.getProjectsDatabases({ name }).pipe(
35 Effect.map((database) =>
36 database.deleteTime !== undefined
37 ? ("gone" as const)
38 : ("ready" as const),
39 ),
40 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
41 Effect.repeat({
42 schedule: Schedule.spaced("3 seconds"),
43 until: (status) => status === want,
44 times: 10,
45 }),
46 );
47
48const waitForDatabaseOperation = (
49 operation: firestore.GoogleLongrunningOperation,
50) =>
51 Effect.gen(function* () {
52 if (operation.done === true || operation.name === undefined) {
53 return operation;
54 }
55 return yield* firestore
56 .getProjectsDatabasesOperations({ name: operation.name })
57 .pipe(
58 Effect.repeat({
59 schedule: Schedule.spaced("4 seconds"),
60 until: (current) => current.done === true,
61 times: 10,
62 }),
63 );
64 });
65
66const ensureEnterpriseDatabase = Effect.gen(function* () {
67 const { project } = yield* GcpEnvironment.current;
68 const existing = yield* firestore
69 .getProjectsDatabases({
70 name: enterpriseDatabaseNameOf(project),
71 })
72 .pipe(
73 Effect.map((database) =>
74 database.deleteTime !== undefined ? undefined : database,
75 ),
76 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
77 );
78 if (
79 existing !== undefined &&
80 (existing.databaseEdition ?? "").toUpperCase() === "ENTERPRISE"
81 ) {
82 return enterpriseDatabaseNameOf(project);
83 }
84 if (existing !== undefined) {
85 yield* firestore
86 .deleteProjectsDatabases({ name: enterpriseDatabaseNameOf(project) })
87 .pipe(Effect.catchTag("NotFound", () => Effect.void));
88 yield* waitUntilDatabase(enterpriseDatabaseNameOf(project), "gone");
89 }
90
91 const created = yield* firestore
92 .createProjectsDatabases({
93 parent: `projects/${project}`,
94 databaseId: enterpriseDatabaseId,
95 body: {
96 locationId: "us-central1",
97 type: "FIRESTORE_NATIVE",
98 databaseEdition: "ENTERPRISE",
99 mongodbCompatibleDataAccessMode: "DATA_ACCESS_MODE_ENABLED",
100 deleteProtectionState: "DELETE_PROTECTION_DISABLED",
101 },
102 })
103 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
104 if (created !== undefined) {
105 yield* waitForDatabaseOperation(created);
106 }
107 yield* waitUntilDatabase(enterpriseDatabaseNameOf(project), "ready");
108 return enterpriseDatabaseNameOf(project);
109});
110
111test.provider(
112 "getProjectsDatabasesUserCreds on a missing user creds fails with NotFound",
113 (stack) =>
114 Effect.gen(function* () {
115 const { project } = yield* GcpEnvironment.current;
116
117 yield* stack.destroy();
118
119 const error = yield* Effect.flip(
120 firestore.getProjectsDatabasesUserCreds({
121 name: `projects/${project}/databases/alchemy-missing-xxxx/userCreds/alchemy-missing`,
122 }),
123 );
124 expect(error._tag).toBe("NotFound");
125
126 yield* stack.destroy();
127 }).pipe(logLevel),
128 { tags: ["provider:gcp", "provider:gcp:firestore", "live"], timeout: 90_000 },
129);
130
131test.provider.skipIf(!!process.env.FAST)(
132 "createProjectsDatabasesUserCreds on Standard edition fails with EnterpriseDatabaseRequired",
133 (stack) =>
134 Effect.gen(function* () {
135 yield* stack.destroy();
136
137 const created = yield* stack.deploy(
138 Effect.gen(function* () {
139 return yield* GCP.Firestore.Database("App", {
140 location: "us-central1",
141 type: "FIRESTORE_NATIVE",
142 });
143 }),
144 );
145
146 const error = yield* Effect.flip(
147 firestore.createProjectsDatabasesUserCreds({
148 parent: created.name,
149 userCredsId: "alchemyusercreds",
150 body: {},
151 }),
152 );
153 expect(error._tag).toEqual("EnterpriseDatabaseRequired");
154
155 yield* stack.destroy();
156 }).pipe(logLevel),
157 {
158 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
159 timeout: 180_000,
160 },
161);
162
163test.provider.skipIf(!runLifecycle)(
164 "create, update, and delete firestore user creds on Enterprise",
165 (stack) =>
166 Effect.gen(function* () {
167 yield* stack.destroy();
168 const parent = yield* ensureEnterpriseDatabase;
169 yield* firestore
170 .deleteProjectsDatabasesUserCreds({
171 name: `${parent}/userCreds/appuser`,
172 })
173 .pipe(Effect.catchTag("NotFound", () => Effect.void));
174
175 const created = yield* stack.deploy(
176 Effect.gen(function* () {
177 return yield* GCP.Firestore.DatabasesUserCred("AppUser", {
178 database: parent,
179 userCredsId: "appuser",
180 });
181 }),
182 );
183
184 expect(created.name).toContain("/userCreds/");
185 expect(created.databaseId).toEqual(enterpriseDatabaseId);
186 expect(created.disabled).toEqual(false);
187 expect(created.securePassword).toEqual(expect.any(String));
188
189 const fetched = yield* firestore.getProjectsDatabasesUserCreds({
190 name: created.name,
191 });
192 expect(fetched.name).toEqual(created.name);
193 expect(fetched.state).toEqual("ENABLED");
194
195 const updated = yield* stack.deploy(
196 Effect.gen(function* () {
197 return yield* GCP.Firestore.DatabasesUserCred("AppUser", {
198 database: parent,
199 userCredsId: created.userCredsId,
200 disabled: true,
201 });
202 }),
203 );
204
205 expect(updated.name).toEqual(created.name);
206 expect(updated.disabled).toEqual(true);
207
208 const refetched = yield* firestore.getProjectsDatabasesUserCreds({
209 name: created.name,
210 });
211 expect(refetched.state).toEqual("DISABLED");
212
213 yield* stack.destroy();
214
215 const gone = yield* waitUntilGone(created.name);
216 expect(gone).toEqual("gone");
217 }).pipe(logLevel),
218 {
219 tags: ["provider:gcp", "provider:gcp:firestore", "live"],
220 timeout: 180_000,
221 },
222);