1import * as Provider from "@/Provider";
2import { Bucket, BucketProvider, type BucketProps } from "@/Prisma/Bucket";
3import {
4 BucketAccessKey,
5 BucketAccessKeyProvider,
6 type BucketAccessKeyProps,
7} from "@/Prisma/BucketAccessKey";
8import {
9 PrismaApiError,
10 PrismaClient,
11 type PrismaManagementClient,
12} from "@/Prisma/Client";
13import type {
14 Bucket as ApiBucket,
15 BucketKey as ApiBucketKey,
16 BucketKeyWithSecret,
17} from "@/Prisma/Types";
18import { describe, expect, it } from "alchemy-test";
19import {
20 dispatchTo,
21 makeFakeManagementApi,
22 unhandled,
23} from "./fixtures/FakeManagementApi.ts";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Redacted from "effect/Redacted";
27import { AlchemyContext } from "@/AlchemyContext";
28
29const createdAt = "2026-01-01T00:00:00.000Z";
30const instanceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
31// physicalBucketAccessKeyName(logical id "BucketAccessKey", instanceId above)
32const expectedKeyName = "BucketAccessKey-aaaaaaaaaaaa";
33
34const apiBucket = (id: string, name: string): ApiBucket => ({
35 id,
36 type: "bucket",
37 url: `https://api.prisma.test/v1/buckets/${id}`,
38 name,
39 providerName: "aws",
40 status: "ready",
41 createdAt,
42 project: {
43 id: "project-1",
44 url: "https://api.prisma.test/v1/projects/project-1",
45 name: "app",
46 },
47 branchId: null,
48});
49
50const bucketAttrs = (id: string, name: string): Bucket["Attributes"] => ({
51 bucketId: id,
52 name,
53 projectId: "project-1",
54 createdAt,
55});
56
57const apiBucketKey = (id: string, name = expectedKeyName): ApiBucketKey => ({
58 id,
59 type: "bucketKey",
60 name,
61 valueHint: "AKIA...",
62 role: "read_write",
63 createdAt,
64});
65
66const apiBucketKeyWithSecret = (id: string): BucketKeyWithSecret => ({
67 ...apiBucketKey(id),
68 accessKeyId: "AKIAEXAMPLE",
69 secretAccessKey: "one-time-secret",
70 endpoint: "https://s3.prisma.test",
71 bucketName: "user-bucket-1",
72});
73
74const persistedKeyAttrs = (id: string): BucketAccessKey["Attributes"] => ({
75 bucketAccessKeyId: id,
76 bucketId: "bucket-1",
77 accessKeyId: "AKIAEXAMPLE",
78 secretAccessKey: Redacted.make("one-time-secret"),
79 endpoint: "https://s3.prisma.test",
80 bucketName: "user-bucket-1",
81});
82
83const apiNotFound = (path: string) =>
84 new PrismaApiError({
85 method: "GET",
86 path,
87 status: 404,
88 message: "HTTP 404",
89 });
90
91const liveProviderContext = Layer.succeed(AlchemyContext, {
92 dotAlchemy: ".alchemy-test",
93 dev: false,
94 adopt: false,
95});
96
97/**
98 * Serve the Management API's bucket routes from the same hermetic
99 * client-shaped handlers this suite already declares. `dispatchTo` maps each
100 * handler's result onto the wire (see the fixture).
101 */
102const bucketApi = (client: any) =>
103 makeFakeManagementApi((request) => {
104 // segments[0] is the "v1" prefix.
105 const [head, bucketId, tail, keyId] = request.pathname
106 .split("/")
107 .filter((segment) => segment.length > 0)
108 .slice(1);
109 const body = request.bodyJson as any;
110 const { call, callVoid, list } = dispatchTo(request);
111
112 if (head !== "buckets") return unhandled(request);
113 if (bucketId === undefined) {
114 return request.method === "GET"
115 ? call(client.listBuckets, [], list)
116 : call(client.createBucket, [body]);
117 }
118 if (tail === "keys") {
119 if (keyId !== undefined) {
120 return callVoid(client.deleteBucketKey, [bucketId, keyId]);
121 }
122 return request.method === "GET"
123 ? call(client.listBucketKeys, [bucketId, { limit: 100 }], list)
124 : call(client.createBucketKey, [bucketId, body]);
125 }
126 if (request.method === "GET") return call(client.getBucket, [bucketId]);
127 if (request.method === "DELETE") {
128 return callVoid(client.deleteBucket, [bucketId]);
129 }
130 return unhandled(request);
131 });
132
133const bucketLayer = (client: PrismaManagementClient) =>
134 BucketProvider().pipe(
135 Layer.provide(Layer.succeed(PrismaClient, client)),
136 Layer.provide(liveProviderContext),
137 Layer.provideMerge(bucketApi(client).layer),
138 );
139
140const bucketKeyLayer = (client: PrismaManagementClient) =>
141 BucketAccessKeyProvider().pipe(
142 Layer.provide(Layer.succeed(PrismaClient, client)),
143 Layer.provide(liveProviderContext),
144 Layer.provideMerge(bucketApi(client).layer),
145 );
146
147const reconcileInput = <Props, Attributes>(
148 id: string,
149 news: Props,
150 output?: Attributes,
151 olds?: Props,
152) => ({
153 id,
154 fqn: id,
155 instanceId,
156 news,
157 olds,
158 output,
159 session: undefined as never,
160 bindings: [],
161});
162
163const diffInput = <Props, Attributes>(
164 id: string,
165 olds: Props,
166 news: Props,
167 output?: Attributes,
168) =>
169 ({
170 id,
171 fqn: id,
172 instanceId,
173 olds,
174 news,
175 output,
176 }) as never;
177
178describe(
179 "Prisma Bucket provider",
180 { tags: ["unit", "provider:prisma", "provider:prisma:bucket", "local"] },
181 () => {
182 it.effect("creates a bucket when none is persisted", () => {
183 let creates = 0;
184 const client = {
185 createBucket: (input: {
186 projectId: string;
187 name?: string;
188 branchId?: string;
189 }) =>
190 Effect.sync(() => {
191 creates += 1;
192 expect(input.projectId).toBe("project-1");
193 expect(input.name).toBe("uploads");
194 expect(input.branchId).toBeUndefined();
195 return apiBucket("bucket-1", "uploads");
196 }),
197 } as unknown as PrismaManagementClient;
198
199 return Effect.gen(function* () {
200 const provider = yield* Bucket.Provider;
201 const attrs = yield* provider.reconcile(
202 reconcileInput("Bucket", { project: "project-1", name: "uploads" }),
203 );
204
205 expect(creates).toBe(1);
206 expect(attrs).toEqual(bucketAttrs("bucket-1", "uploads"));
207 }).pipe(Effect.provide(bucketLayer(client)));
208 });
209
210 it.effect("returns the observed bucket without creating again", () => {
211 let creates = 0;
212 const client = {
213 getBucket: (id: string) => Effect.succeed(apiBucket(id, "uploads")),
214 createBucket: () =>
215 Effect.sync(() => {
216 creates += 1;
217 return apiBucket("bucket-2", "uploads");
218 }),
219 } as unknown as PrismaManagementClient;
220
221 return Effect.gen(function* () {
222 const provider = yield* Bucket.Provider;
223 const attrs = yield* provider.reconcile(
224 reconcileInput(
225 "Bucket",
226 { project: "project-1", name: "uploads" },
227 bucketAttrs("bucket-1", "uploads"),
228 ),
229 );
230
231 expect(creates).toBe(0);
232 expect(attrs.bucketId).toBe("bucket-1");
233 }).pipe(Effect.provide(bucketLayer(client)));
234 });
235
236 it.effect("refuses convergence when the bucket moved projects", () => {
237 const client = {
238 getBucket: (id: string) =>
239 Effect.succeed({
240 ...apiBucket(id, "uploads"),
241 project: {
242 id: "project-other",
243 url: "https://api.prisma.test/v1/projects/project-other",
244 name: "other",
245 },
246 }),
247 } as unknown as PrismaManagementClient;
248
249 return Effect.gen(function* () {
250 const provider = yield* Bucket.Provider;
251 const result = yield* provider
252 .reconcile(
253 reconcileInput(
254 "Bucket",
255 { project: "project-1", name: "uploads" },
256 bucketAttrs("bucket-1", "uploads"),
257 ),
258 )
259 .pipe(Effect.flip);
260
261 expect(String(result)).toContain("Refusing to claim convergence");
262 }).pipe(Effect.provide(bucketLayer(client)));
263 });
264
265 it.effect("recreates the bucket when the persisted one is gone", () => {
266 let creates = 0;
267 const client = {
268 getBucket: (id: string) =>
269 Effect.fail(apiNotFound(`/v1/buckets/${id}`)),
270 createBucket: () =>
271 Effect.sync(() => {
272 creates += 1;
273 return apiBucket("bucket-2", "uploads");
274 }),
275 } as unknown as PrismaManagementClient;
276
277 return Effect.gen(function* () {
278 const provider = yield* Bucket.Provider;
279 const attrs = yield* provider.reconcile(
280 reconcileInput(
281 "Bucket",
282 { project: "project-1", name: "uploads" },
283 bucketAttrs("bucket-1", "uploads"),
284 ),
285 );
286
287 expect(creates).toBe(1);
288 expect(attrs.bucketId).toBe("bucket-2");
289 }).pipe(Effect.provide(bucketLayer(client)));
290 });
291
292 it.effect("read refreshes from the API and reports a gone bucket", () => {
293 const client = {
294 getBucket: (id: string) =>
295 id === "bucket-1"
296 ? Effect.succeed(apiBucket(id, "uploads"))
297 : Effect.fail(apiNotFound(`/v1/buckets/${id}`)),
298 } as unknown as PrismaManagementClient;
299
300 return Effect.gen(function* () {
301 const provider = yield* Provider.findProvider(Bucket);
302 const observed = yield* provider.read!({
303 id: "Bucket",
304 fqn: "Bucket",
305 instanceId,
306 olds: { project: "project-1", name: "uploads" },
307 output: bucketAttrs("bucket-1", "uploads"),
308 });
309 expect(observed).toEqual(bucketAttrs("bucket-1", "uploads"));
310
311 const gone = yield* provider.read!({
312 id: "Bucket",
313 fqn: "Bucket",
314 instanceId,
315 olds: { project: "project-1", name: "uploads" },
316 output: bucketAttrs("bucket-2", "uploads"),
317 });
318 expect(gone).toBeUndefined();
319 }).pipe(Effect.provide(bucketLayer(client)));
320 });
321
322 it.effect("replaces on project, name, or branch changes", () => {
323 const client = {} as unknown as PrismaManagementClient;
324 const olds: BucketProps = { project: "project-1", name: "uploads" };
325 const output = bucketAttrs("bucket-1", "uploads");
326
327 return Effect.gen(function* () {
328 const provider = yield* Bucket.Provider;
329
330 expect(
331 yield* provider.diff!(
332 diffInput(
333 "Bucket",
334 olds,
335 { project: "project-2", name: "uploads" },
336 output,
337 ),
338 ),
339 ).toEqual({ action: "replace" });
340 expect(
341 yield* provider.diff!(
342 diffInput(
343 "Bucket",
344 olds,
345 { project: "project-1", name: "renamed" },
346 output,
347 ),
348 ),
349 ).toEqual({ action: "replace" });
350 expect(
351 yield* provider.diff!(
352 diffInput(
353 "Bucket",
354 olds,
355 { project: "project-1", name: "uploads", branchId: "branch-1" },
356 output,
357 ),
358 ),
359 ).toEqual({ action: "replace" });
360 expect(
361 yield* provider.diff!(diffInput("Bucket", olds, olds, output)),
362 ).toBeUndefined();
363 }).pipe(Effect.provide(bucketLayer(client)));
364 });
365
366 it.effect("delete verifies identity and tolerates a gone bucket", () => {
367 let deletes = 0;
368 const client = {
369 getBucket: (id: string) =>
370 id === "bucket-1"
371 ? Effect.succeed(apiBucket(id, "uploads"))
372 : Effect.fail(apiNotFound(`/v1/buckets/${id}`)),
373 deleteBucket: (id: string) =>
374 Effect.sync(() => {
375 deletes += 1;
376 }).pipe(
377 Effect.andThen(Effect.fail(apiNotFound(`/v1/buckets/${id}`))),
378 ),
379 } as unknown as PrismaManagementClient;
380
381 return Effect.gen(function* () {
382 const provider = yield* Bucket.Provider;
383 yield* provider.delete({
384 id: "Bucket",
385 fqn: "Bucket",
386 instanceId,
387 olds: { project: "project-1", name: "uploads" },
388 output: bucketAttrs("bucket-1", "uploads"),
389 session: undefined as never,
390 } as never);
391 expect(deletes).toBe(1);
392
393 // Already gone: delete is a no-op.
394 yield* provider.delete({
395 id: "Bucket",
396 fqn: "Bucket",
397 instanceId,
398 olds: { project: "project-1", name: "uploads" },
399 output: bucketAttrs("bucket-2", "uploads"),
400 session: undefined as never,
401 } as never);
402 expect(deletes).toBe(1);
403
404 // Drifted to another project: refuse to delete.
405 const drifted = yield* provider
406 .delete({
407 id: "Bucket",
408 fqn: "Bucket",
409 instanceId,
410 olds: { project: "project-1", name: "uploads" },
411 output: { ...bucketAttrs("bucket-1", "uploads"), projectId: "p2" },
412 session: undefined as never,
413 } as never)
414 .pipe(Effect.flip);
415 expect(String(drifted)).toContain("Refusing to delete");
416 }).pipe(Effect.provide(bucketLayer(client)));
417 });
418 },
419);
420
421describe(
422 "Prisma BucketAccessKey provider",
423 {
424 tags: [
425 "unit",
426 "provider:prisma",
427 "provider:prisma:bucket",
428 "provider:prisma:bucketaccesskey",
429 "local",
430 ],
431 },
432 () => {
433 it.effect(
434 "creates a key under its deterministic name and redacts the secret",
435 () => {
436 let creates = 0;
437 const client = {
438 listBucketKeys: () => Effect.succeed([]),
439 createBucketKey: (
440 bucketId: string,
441 input: { name?: string; role: string },
442 ) =>
443 Effect.sync(() => {
444 creates += 1;
445 expect(bucketId).toBe("bucket-1");
446 expect(input.name).toBe(expectedKeyName);
447 expect(input.role).toBe("read_write");
448 return apiBucketKeyWithSecret("key-1");
449 }),
450 } as unknown as PrismaManagementClient;
451
452 return Effect.gen(function* () {
453 const provider = yield* BucketAccessKey.Provider;
454 const attrs = yield* provider.reconcile(
455 reconcileInput("BucketAccessKey", {
456 bucket: "bucket-1",
457 role: "read_write" as const,
458 }),
459 );
460
461 expect(creates).toBe(1);
462 expect(attrs.bucketAccessKeyId).toBe("key-1");
463 expect(attrs.bucketId).toBe("bucket-1");
464 expect(attrs.accessKeyId).toBe("AKIAEXAMPLE");
465 expect(Redacted.value(attrs.secretAccessKey)).toBe("one-time-secret");
466 expect(attrs.endpoint).toBe("https://s3.prisma.test");
467 // The provider-side S3 bucket name, not the friendly display name.
468 expect(attrs.bucketName).toBe("user-bucket-1");
469 }).pipe(Effect.provide(bucketKeyLayer(client)));
470 },
471 );
472
473 it.effect(
474 "revokes an orphaned key from a lost create response before recreating",
475 () => {
476 // Simulates a crash after POST /keys but before state persist: the
477 // retry sees no output, finds the deterministic name already taken,
478 // revokes it (its secret is unrecoverable), and mints a fresh key.
479 let deletes = 0;
480 let creates = 0;
481 const client = {
482 listBucketKeys: () => Effect.succeed([apiBucketKey("key-orphan")]),
483 deleteBucketKey: (bucketId: string, keyId: string) =>
484 Effect.sync(() => {
485 deletes += 1;
486 expect(bucketId).toBe("bucket-1");
487 expect(keyId).toBe("key-orphan");
488 }),
489 createBucketKey: () =>
490 Effect.sync(() => {
491 creates += 1;
492 return apiBucketKeyWithSecret("key-2");
493 }),
494 } as unknown as PrismaManagementClient;
495
496 return Effect.gen(function* () {
497 const provider = yield* BucketAccessKey.Provider;
498 const attrs = yield* provider.reconcile(
499 reconcileInput("BucketAccessKey", {
500 bucket: "bucket-1",
501 role: "read_write" as const,
502 }),
503 );
504
505 expect(deletes).toBe(1);
506 expect(creates).toBe(1);
507 expect(attrs.bucketAccessKeyId).toBe("key-2");
508 }).pipe(Effect.provide(bucketKeyLayer(client)));
509 },
510 );
511
512 it.effect(
513 "fails with a tagged error when two keys share the recovery name",
514 () => {
515 // Two keys under the deterministic name leave nothing to recover:
516 // picking either could revoke a key another deploy is using.
517 const client = {
518 listBucketKeys: () =>
519 Effect.succeed([apiBucketKey("key-a"), apiBucketKey("key-b")]),
520 } as unknown as PrismaManagementClient;
521
522 return Effect.gen(function* () {
523 const provider = yield* BucketAccessKey.Provider;
524 const error = yield* provider
525 .reconcile(
526 reconcileInput("BucketAccessKey", {
527 bucket: "bucket-1",
528 role: "read_write" as const,
529 }),
530 )
531 .pipe(Effect.flip);
532
533 expect(error._tag).toBe("AmbiguousBucketAccessKeyError");
534 expect(error.message).toContain("refusing to select one arbitrarily");
535 }).pipe(Effect.provide(bucketKeyLayer(client)));
536 },
537 );
538
539 it.effect(
540 "returns persisted attributes while the key exists, without re-creating",
541 () => {
542 let creates = 0;
543 const client = {
544 listBucketKeys: () => Effect.succeed([apiBucketKey("key-1")]),
545 createBucketKey: () =>
546 Effect.sync(() => {
547 creates += 1;
548 return apiBucketKeyWithSecret("key-2");
549 }),
550 } as unknown as PrismaManagementClient;
551
552 const persisted = persistedKeyAttrs("key-1");
553
554 return Effect.gen(function* () {
555 const provider = yield* Provider.findProvider(BucketAccessKey);
556 const attrs = yield* provider.reconcile(
557 reconcileInput(
558 "BucketAccessKey",
559 { bucket: "bucket-1", role: "read_write" as const },
560 persisted,
561 { bucket: "bucket-1", role: "read_write" as const },
562 ),
563 );
564
565 // The secret is returned exactly once at creation; persisted state
566 // stays authoritative while the key exists.
567 expect(creates).toBe(0);
568 expect(attrs).toBe(persisted);
569
570 const observed = yield* provider.read!({
571 id: "BucketAccessKey",
572 fqn: "BucketAccessKey",
573 instanceId,
574 olds: { bucket: "bucket-1", role: "read_write" as const },
575 output: persisted,
576 });
577 expect(observed).toBe(persisted);
578 }).pipe(Effect.provide(bucketKeyLayer(client)));
579 },
580 );
581
582 it.effect("mints fresh credentials when the key was revoked", () => {
583 let creates = 0;
584 const client = {
585 listBucketKeys: () => Effect.succeed([]),
586 createBucketKey: () =>
587 Effect.sync(() => {
588 creates += 1;
589 return apiBucketKeyWithSecret("key-2");
590 }),
591 } as unknown as PrismaManagementClient;
592
593 const persisted = persistedKeyAttrs("key-1");
594
595 return Effect.gen(function* () {
596 const provider = yield* Provider.findProvider(BucketAccessKey);
597
598 const observed = yield* provider.read!({
599 id: "BucketAccessKey",
600 fqn: "BucketAccessKey",
601 instanceId,
602 olds: { bucket: "bucket-1", role: "read_write" as const },
603 output: persisted,
604 });
605 expect(observed).toBeUndefined();
606
607 const attrs = yield* provider.reconcile(
608 reconcileInput(
609 "BucketAccessKey",
610 { bucket: "bucket-1", role: "read_write" as const },
611 persisted,
612 { bucket: "bucket-1", role: "read_write" as const },
613 ),
614 );
615 expect(creates).toBe(1);
616 expect(attrs.bucketAccessKeyId).toBe("key-2");
617 }).pipe(Effect.provide(bucketKeyLayer(client)));
618 });
619
620 it.effect("replaces on bucket, role, or name changes", () => {
621 const client = {} as unknown as PrismaManagementClient;
622 const olds: BucketAccessKeyProps = {
623 bucket: "bucket-1",
624 role: "read_write",
625 };
626 const output = persistedKeyAttrs("key-1");
627
628 return Effect.gen(function* () {
629 const provider = yield* BucketAccessKey.Provider;
630
631 expect(
632 yield* provider.diff!(
633 diffInput(
634 "BucketAccessKey",
635 olds,
636 { bucket: "bucket-2", role: "read_write" as const },
637 output,
638 ),
639 ),
640 ).toEqual({ action: "replace" });
641 expect(
642 yield* provider.diff!(
643 diffInput(
644 "BucketAccessKey",
645 olds,
646 { bucket: "bucket-1", role: "read" as const },
647 output,
648 ),
649 ),
650 ).toEqual({ action: "replace" });
651 expect(
652 yield* provider.diff!(
653 diffInput(
654 "BucketAccessKey",
655 olds,
656 { bucket: "bucket-1", role: "read_write" as const, name: "next" },
657 output,
658 ),
659 ),
660 ).toEqual({ action: "replace" });
661 expect(
662 yield* provider.diff!(
663 diffInput("BucketAccessKey", olds, olds, output),
664 ),
665 ).toBeUndefined();
666 }).pipe(Effect.provide(bucketKeyLayer(client)));
667 });
668
669 it.effect("delete tolerates keys already revoked by bucket cascade", () => {
670 let deletes = 0;
671 const client = {
672 deleteBucketKey: (bucketId: string, keyId: string) =>
673 Effect.sync(() => {
674 deletes += 1;
675 expect(bucketId).toBe("bucket-1");
676 expect(keyId).toBe("key-1");
677 }).pipe(
678 Effect.andThen(
679 Effect.fail(apiNotFound("/v1/buckets/bucket-1/keys/key-1")),
680 ),
681 ),
682 } as unknown as PrismaManagementClient;
683
684 return Effect.gen(function* () {
685 const provider = yield* BucketAccessKey.Provider;
686 yield* provider.delete({
687 id: "BucketAccessKey",
688 fqn: "BucketAccessKey",
689 instanceId,
690 olds: { bucket: "bucket-1", role: "read_write" },
691 output: persistedKeyAttrs("key-1"),
692 session: undefined as never,
693 } as never);
694
695 expect(deletes).toBe(1);
696 }).pipe(Effect.provide(bucketKeyLayer(client)));
697 });
698 },
699);