1import * as Planetscale from "@/Planetscale";
2import * as Provider from "@/Provider";
3import * as RemovalPolicy from "@/RemovalPolicy.ts";
4import * as Test from "@/Test/Alchemy";
5import * as ps from "@distilled.cloud/planetscale";
6import { describe, expect } from "alchemy-test";
7import { Data, Schedule } from "effect";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import { MinimumLogLevel } from "effect/References";
12
13const { test } = Test.make({
14 providers: Planetscale.providers(),
15});
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22describe
23 .skipIf(!process.env.PLANETSCALE_TEST)
24 .concurrent(
25 "MySQLPassword",
26 { tags: ["provider:planetscale", "provider:planetscale:mysql", "live"] },
27 () => {
28 // Read-only: PARENT FAN-OUT enumeration (org -> databases -> branches ->
29 // passwords) against the live org, without provisioning anything.
30 test.provider("list enumerates passwords (read-only)", () =>
31 Effect.gen(function* () {
32 const provider = yield* Provider.findProvider(
33 Planetscale.MySQLPassword,
34 );
35 const all = yield* provider.list();
36
37 expect(Array.isArray(all)).toBe(true);
38 for (const p of all) {
39 expect(p).toMatchObject({
40 id: expect.any(String),
41 name: expect.any(String),
42 organization: expect.any(String),
43 database: expect.any(String),
44 branch: expect.any(String),
45 });
46 }
47 }).pipe(logLevel),
48 );
49
50 // Deploy-and-find coverage, opt-in only (slow provisioning).
51 test.provider.skipIf(!process.env.PLANETSCALE_DEPLOY_TEST)(
52 "list finds a freshly deployed password",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 const { database, password } = yield* stack.deploy(
58 Effect.gen(function* () {
59 const database = yield* Planetscale.MySQLDatabase("ListDb", {
60 name: "alchemy-mysql-pw-list",
61 clusterSize: "PS_10",
62 });
63 const password = yield* Planetscale.MySQLPassword(
64 "ListPassword",
65 {
66 database,
67 role: "reader",
68 },
69 );
70 return { database, password };
71 }),
72 );
73
74 const provider = yield* Provider.findProvider(
75 Planetscale.MySQLPassword,
76 );
77 const all = yield* provider.list();
78
79 expect(
80 all.some(
81 (p) =>
82 p.organization === database.organization &&
83 p.database === database.name &&
84 p.id === password.id,
85 ),
86 ).toBe(true);
87
88 yield* stack.destroy();
89 yield* waitForDatabaseToBeDeleted(
90 database.name,
91 database.organization,
92 );
93 }).pipe(logLevel),
94 5_000_000,
95 );
96
97 test.provider(
98 "create, update, and delete password",
99 (stack) =>
100 Effect.gen(function* () {
101 yield* stack.destroy();
102
103 const { database, branch, password } = yield* stack.deploy(
104 Effect.gen(function* () {
105 const database = yield* Planetscale.MySQLDatabase("Database", {
106 clusterSize: "PS_10",
107 });
108
109 const branch = yield* Planetscale.MySQLBranch("Branch", {
110 database,
111 parentBranch: "main",
112 isProduction: false,
113 });
114
115 const password = yield* Planetscale.MySQLPassword("Password", {
116 database,
117 branch,
118 role: "reader",
119 });
120
121 return { database, branch, password };
122 }),
123 );
124
125 expect(password).toMatchObject({
126 id: expect.any(String),
127 name: expect.any(String),
128 role: "reader",
129 host: expect.any(String),
130 username: expect.any(String),
131 organization: expect.any(String),
132 database: database.name,
133 branch: branch.name,
134 });
135
136 // Verify password was created by querying the API directly
137 const fetched = yield* ps.getPassword({
138 organization: database.organization,
139 database: database.name,
140 branch: branch.name,
141 id: password.id,
142 });
143
144 expect(fetched.id).toEqual(password.id);
145 expect(fetched.name).toEqual(password.name);
146 expect(fetched.role).toEqual("reader");
147
148 // Update the password (only name and cidrs should trigger update, not replace)
149 const { updatedPassword } = yield* stack.deploy(
150 Effect.gen(function* () {
151 const sameDatabase = yield* Planetscale.MySQLDatabase(
152 "Database",
153 {
154 clusterSize: "PS_10",
155 },
156 );
157
158 const sameBranch = yield* Planetscale.MySQLBranch("Branch", {
159 database: sameDatabase,
160 parentBranch: "main",
161 isProduction: false,
162 });
163
164 const updatedPassword = yield* Planetscale.MySQLPassword(
165 "Password",
166 {
167 name: "test-updated-password-name",
168 database: sameDatabase.name,
169 branch: sameBranch.name,
170 role: "reader",
171 },
172 );
173
174 return { updatedPassword };
175 }),
176 );
177
178 expect(updatedPassword.id).toEqual(password.id);
179 expect(updatedPassword.name).not.toEqual(password.name);
180
181 // Verify password was updated
182 const fetchedUpdated = yield* ps.getPassword({
183 organization: database.organization,
184 database: database.name,
185 branch: branch.name,
186 id: updatedPassword.id,
187 });
188
189 expect(fetchedUpdated.id).toEqual(password.id);
190 expect(fetchedUpdated.name).toEqual(updatedPassword.name);
191
192 yield* stack.destroy();
193
194 yield* waitForDatabaseToBeDeleted(
195 database.name,
196 database.organization,
197 );
198 }).pipe(logLevel),
199 5_000_000,
200 );
201
202 test.provider(
203 "password gets replaced when properties other than name and cidrs change",
204 (stack) =>
205 Effect.gen(function* () {
206 yield* stack.destroy();
207
208 const { database, branch, password } = yield* stack.deploy(
209 Effect.gen(function* () {
210 const database = yield* Planetscale.MySQLDatabase("Database", {
211 clusterSize: "PS_10",
212 });
213
214 const branch = yield* Planetscale.MySQLBranch("Branch", {
215 database,
216 parentBranch: "main",
217 isProduction: false,
218 });
219
220 const password = yield* Planetscale.MySQLPassword("Password", {
221 database,
222 branch,
223 role: "reader",
224 ttl: 3600,
225 cidrs: ["0.0.0.0/0"],
226 });
227 return { database, branch, password };
228 }),
229 );
230
231 const originalId = password.id;
232 expect(password.role).toEqual("reader");
233 expect(password.ttl).toEqual(3600);
234
235 // Change role from reader -> writer (should trigger replace).
236 const { replacedPassword } = yield* stack.deploy(
237 Effect.gen(function* () {
238 const database = yield* Planetscale.MySQLDatabase("Database", {
239 clusterSize: "PS_10",
240 });
241
242 const branch = yield* Planetscale.MySQLBranch("Branch", {
243 database,
244 parentBranch: "main",
245 isProduction: false,
246 });
247
248 const replacedPassword = yield* Planetscale.MySQLPassword(
249 "Password",
250 {
251 database,
252 branch,
253 role: "writer",
254 ttl: 3600,
255 cidrs: ["0.0.0.0/0"],
256 },
257 );
258 return { replacedPassword };
259 }),
260 );
261
262 // New ID due to replacement.
263 expect(replacedPassword.id).not.toEqual(originalId);
264 expect(replacedPassword.role).toEqual("writer");
265
266 // Old password should have been deleted as part of the replace.
267 const oldExit = yield* ps
268 .getPassword({
269 organization: database.organization,
270 database: database.name,
271 branch: branch.name,
272 id: originalId,
273 })
274 .pipe(Effect.exit);
275 expect(Exit.isFailure(oldExit)).toBe(true);
276 if (Exit.isFailure(oldExit)) {
277 expect(Cause.pretty(oldExit.cause)).toContain("NotFound");
278 }
279
280 // New password exists with the new role.
281 const newFetched = yield* ps.getPassword({
282 organization: database.organization,
283 database: database.name,
284 branch: branch.name,
285 id: replacedPassword.id,
286 });
287 expect(newFetched.id).toEqual(replacedPassword.id);
288 expect(newFetched.role).toEqual("writer");
289
290 yield* stack.destroy();
291 yield* waitForDatabaseToBeDeleted(
292 database.name,
293 database.organization,
294 );
295 }).pipe(logLevel),
296 5_000_000,
297 );
298
299 test.provider(
300 "password with RemovalPolicy.retain(true) should not be deleted via API",
301 (stack) =>
302 Effect.gen(function* () {
303 const dbName = `alchemy-test-pwd-retain`;
304 const passwordName = `retain-password`;
305
306 yield* stack.destroy();
307
308 const { database, password } = yield* stack.deploy(
309 Effect.gen(function* () {
310 // Retain the database too — otherwise deleting it would cascade
311 // to the password and we couldn't observe the retain behavior.
312 const database = yield* Planetscale.MySQLDatabase("Database", {
313 name: dbName,
314 clusterSize: "PS_10",
315 }).pipe(RemovalPolicy.retain(true));
316 const password = yield* Planetscale.MySQLPassword("Password", {
317 name: passwordName,
318 database,
319 role: "reader",
320 }).pipe(RemovalPolicy.retain(true));
321 return { database, password };
322 }),
323 );
324
325 // Password exists post-deploy.
326 const fetched = yield* ps.getPassword({
327 organization: database.organization,
328 database: database.name,
329 branch: "main",
330 id: password.id,
331 });
332 expect(fetched.id).toEqual(password.id);
333
334 // Destroy the stack — both retained, so neither should be removed.
335 yield* stack.destroy();
336
337 const { organization } = yield* yield* Planetscale.Credentials;
338
339 // Database should still exist and be ready.
340 const liveDb = yield* Planetscale.waitForDatabaseReady(
341 organization,
342 dbName,
343 );
344 expect(liveDb.name).toEqual(dbName);
345
346 // Password should still exist (was not deleted via API).
347 const stillExists = yield* ps.getPassword({
348 organization,
349 database: dbName,
350 branch: "main",
351 id: password.id,
352 });
353 expect(stillExists.id).toEqual(password.id);
354 expect(stillExists.name).toEqual(password.name);
355
356 // Manual cleanup for the test.
357 yield* ps
358 .deletePassword({
359 organization,
360 database: dbName,
361 branch: "main",
362 id: password.id,
363 })
364 .pipe(Effect.catchTag("NotFound", () => Effect.void));
365
366 yield* ps
367 .deleteDatabase({
368 organization,
369 database: dbName,
370 })
371 .pipe(Effect.catchTag("NotFound", () => Effect.void));
372
373 yield* waitForDatabaseToBeDeleted(dbName, organization);
374 }).pipe(logLevel),
375 5_000_000,
376 );
377 },
378 );
379
380const waitForDatabaseToBeDeleted = Effect.fn(function* (
381 database: string,
382 organization: string,
383) {
384 yield* ps
385 .getDatabase({
386 organization,
387 database,
388 })
389 .pipe(
390 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
391 Effect.retry({
392 while: (e): e is DatabaseStillExists =>
393 e instanceof DatabaseStillExists,
394 schedule: Schedule.exponential(100),
395 }),
396 Effect.catchTag("NotFound", () => Effect.void),
397 );
398});
399
400class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}