1import { providers } from "@/Neon/Providers";
2import { Function as NeonFunction } from "@/Neon/Function";
3import { bucketStorageClient } from "@/Neon/Bucket";
4import * as Redacted from "effect/Redacted";
5import * as Test from "@/Test/Alchemy";
6import * as SDK from "@distilled.cloud/neon";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpBody from "effect/http/HttpBody";
11import * as Path from "effect/Path";
12import * as Schedule from "effect/Schedule";
13import StorageFunction from "./fixtures/StorageFunction.ts";
14import { StorageBucket, StorageSettings } from "./fixtures/StorageResources.ts";
15
16const { test } = Test.make({ providers: providers() });
17
18test.provider(
19 "injected Function credentials preserve typed object DX without extra customer credentials",
20 (stack) =>
21 Effect.gen(function* () {
22 yield* stack.destroy();
23 const { fn, bucket } = yield* stack.deploy(
24 Effect.gen(function* () {
25 const bucket = yield* StorageBucket;
26 yield* StorageSettings;
27 const fn = yield* StorageFunction;
28 return { fn, bucket };
29 }),
30 );
31 const credentials = yield* SDK.listCredentials({
32 project_id: bucket.projectId,
33 branch_id: bucket.branchId,
34 });
35 expect(
36 credentials.credentials
37 .filter(
38 (credential) =>
39 credential.principal_type === "user" &&
40 credential.branch_id === bucket.branchId &&
41 !credential.revoked_at,
42 )
43 .map((credential) => credential.name),
44 ).toHaveLength(1);
45 const http = yield* HttpClient.HttpClient;
46 const initial = yield* HttpClient.filterStatusOk(http)
47 .get(fn.url)
48 .pipe(
49 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 8 }),
50 );
51 expect(initial.status).toBe(200);
52 expect(yield* initial.json).toEqual({ theme: "system", pageSize: 25 });
53 const upload = (yield* (yield* http.get(`${fn.url}/presign`)).json) as {
54 url: string;
55 };
56 expect(
57 (yield* http.put(upload.url, {
58 body: HttpBody.text("effect presign", "text/plain"),
59 })).status,
60 ).toBe(200);
61 const download = (yield* (yield* http.get(`${fn.url}/download`))
62 .json) as { url: string };
63 expect(yield* (yield* http.get(download.url)).text).toBe(
64 "effect presign",
65 );
66 const written = yield* http.get(`${fn.url}/write`);
67 if (written.status !== 200)
68 yield* Effect.log("Storage write failed", {
69 tag: written.headers["x-storage-error"] ?? "unclassified",
70 });
71 expect(written.status).toBe(200);
72 yield* Effect.forEach(
73 Array.from({ length: 8 }, (_, index) => index),
74 () =>
75 Effect.gen(function* () {
76 const repeated = yield* http.get(`${fn.url}/write`);
77 if (repeated.status !== 200)
78 yield* Effect.log("Storage write failed", {
79 tag: repeated.headers["x-storage-error"] ?? "unclassified",
80 });
81 expect(repeated.status).toBe(200);
82 }),
83 );
84 expect(yield* (yield* http.get(fn.url)).json).toEqual({
85 theme: "dark",
86 pageSize: 50,
87 });
88 expect(yield* (yield* http.get(`${fn.url}/invalid`)).text).toBe(
89 "rejected",
90 );
91 const invalidRead = yield* http.get(fn.url);
92 expect(invalidRead.status).toBe(500);
93 expect(yield* invalidRead.text).toBe("Storage request failed");
94 yield* stack.destroy();
95 expect(
96 yield* SDK.getProject({ project_id: bucket.projectId }).pipe(
97 Effect.as(false),
98 Effect.catchTag("NotFound", () => Effect.succeed(true)),
99 ),
100 ).toBe(true);
101 yield* stack.destroy();
102 }),
103 {
104 tags: [
105 "provider:neon",
106 "provider:neon:branch",
107 "provider:neon:bucket",
108 "provider:neon:function",
109 "provider:neon:object",
110 "provider:neon:project",
111 "live",
112 ],
113 timeout: 120_000,
114 },
115);
116
117test.provider(
118 "native injected S3 credentials support authenticated and presigned roundtrips",
119 (stack) =>
120 Effect.gen(function* () {
121 yield* stack.destroy();
122 const { fn, bucket } = yield* stack.deploy(
123 Effect.gen(function* () {
124 const bucket = yield* StorageBucket;
125 const fn = yield* NeonFunction("StorageNative", {
126 branch: bucket.Props.branch!,
127 main: (yield* Path.Path).join(
128 import.meta.dirname,
129 "fixtures",
130 "StorageNative.ts",
131 ),
132 env: {
133 BUCKET_NAME: bucket.bucketName,
134 APP_TOKEN: Redacted.make("neon-storage-fixture-token"),
135 },
136 });
137 return { fn, bucket };
138 }),
139 );
140 const http = yield* HttpClient.HttpClient;
141 const headers = { authorization: "Bearer neon-storage-fixture-token" };
142 expect((yield* http.get(fn.url)).status).toBe(401);
143 const presign = yield* HttpClient.filterStatusOk(http)
144 .get(`${fn.url}/presign`, { headers })
145 .pipe(
146 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 8 }),
147 );
148 const { url } = (yield* presign.json) as { url: string };
149 const upload = yield* http.put(url, {
150 body: HttpBody.text("native roundtrip"),
151 });
152 expect(upload.status).toBe(200);
153 const download = yield* http.get(fn.url, { headers });
154 expect(yield* download.text).toBe("native roundtrip");
155 expect(
156 (yield* (yield* bucketStorageClient(bucket)).head("native.txt"))
157 ?.ContentLength,
158 ).toBe(16);
159 yield* stack.destroy();
160 yield* stack.destroy();
161 }),
162 {
163 tags: [
164 "provider:neon",
165 "provider:neon:branch",
166 "provider:neon:bucket",
167 "provider:neon:function",
168 "provider:neon:object",
169 "provider:neon:project",
170 "live",
171 ],
172 timeout: 120_000,
173 },
174);