1import { Auth } from "@/Neon/Auth.ts";
2import { AuthTrustedDomain } from "@/Neon/AuthTrustedDomain.ts";
3import { Branch } from "@/Neon/Branch.ts";
4import { Project } from "@/Neon/Project.ts";
5import { providers } from "@/Neon/Providers.ts";
6import * as Test from "@/Test/Alchemy";
7import * as SDK from "@distilled.cloud/neon";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13
14const { test } = Test.make({ providers: providers() });
15
16const application = (allowLocalhost: boolean, domain: string) =>
17 Effect.gen(function* () {
18 const project = yield* Project("ManagedAuthProject", {
19 region: "aws-us-east-2",
20 });
21 const branch = yield* Branch("ManagedAuthBranch", { project });
22 const auth = yield* Auth("ManagedAuth", {
23 branch,
24 name: "Alchemy managed auth",
25 allowLocalhost,
26 emailAndPassword: {
27 enabled: true,
28 require_email_verification: false,
29 send_verification_email_on_sign_up: false,
30 },
31 });
32 const origin = yield* AuthTrustedDomain("TrustedOrigin", { auth, domain });
33 return { project, branch, auth, origin };
34 });
35
36test.provider(
37 "managed Auth observes settings, replaces only the trusted origin, and destroys idempotently",
38 (stack) =>
39 Effect.gen(function* () {
40 yield* stack.destroy();
41 const first = yield* stack.deploy(
42 application(false, "https://auth.example.com"),
43 );
44 const request = {
45 project_id: first.auth.projectId,
46 branch_id: first.auth.branchId,
47 };
48 const current = yield* SDK.getNeonAuth(request);
49 expect(current.auth_provider).toBe("better_auth");
50 expect(current.base_url).toBe(first.auth.baseUrl);
51 expect(first.auth.jwksUrl).toMatch(/^https:\/\//);
52 expect(
53 (yield* SDK.getNeonAuthAllowLocalhost(request)).allow_localhost,
54 ).toBe(false);
55
56 const unchanged = yield* stack.deploy(
57 application(false, "https://auth.example.com"),
58 );
59 expect(unchanged.auth.baseUrl).toBe(first.auth.baseUrl);
60 yield* SDK.updateNeonAuthAllowLocalhost({
61 ...request,
62 allow_localhost: true,
63 });
64 const updated = yield* stack.deploy(
65 application(false, "https://next.example.com"),
66 );
67 expect(updated.auth.baseUrl).toBe(first.auth.baseUrl);
68 expect(
69 (yield* SDK.getNeonAuthAllowLocalhost(request)).allow_localhost,
70 ).toBe(false);
71 const domains = yield* SDK.listBranchNeonAuthTrustedDomains(request);
72 expect(
73 domains.domains.some(
74 (entry) => entry.domain === "https://next.example.com",
75 ),
76 ).toBe(true);
77 expect(
78 domains.domains.some(
79 (entry) => entry.domain === "https://auth.example.com",
80 ),
81 ).toBe(false);
82
83 yield* stack.destroy();
84 const gone = yield* SDK.getNeonAuth(request).pipe(
85 Effect.as(false),
86 Effect.catchTag("NotFound", () => Effect.succeed(true)),
87 );
88 expect(gone).toBe(true);
89 yield* stack.destroy();
90 }),
91 {
92 tags: [
93 "provider:neon",
94 "provider:neon:auth",
95 "provider:neon:authtrusteddomain",
96 "provider:neon:branch",
97 "provider:neon:project",
98 "live",
99 ],
100 timeout: 120_000,
101 },
102);
103
104test.provider(
105 "Auth preserves equivalent database identity, supports shared email, and rejects removals before mutation",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109 const program = (database?: string, remove = false) =>
110 Effect.gen(function* () {
111 const project = yield* Project("AuthEquivalentProject", {
112 region: "aws-us-east-2",
113 });
114 const auth = yield* Auth("AuthEquivalent", {
115 project,
116 database,
117 name: remove ? "must-not-be-applied" : "Equivalent Auth",
118 allowLocalhost: remove ? undefined : false,
119 emailProvider: { type: "shared" },
120 magicLink: { enabled: false },
121 });
122 return { auth };
123 });
124 const first = yield* stack.deploy(program());
125 const request = {
126 project_id: first.auth.projectId,
127 branch_id: first.auth.branchId,
128 };
129 const explicit = yield* stack.deploy(program(first.auth.database));
130 expect(explicit.auth.baseUrl).toBe(first.auth.baseUrl);
131 const implicit = yield* stack.deploy(program());
132 expect(implicit.auth.baseUrl).toBe(first.auth.baseUrl);
133 expect(implicit.auth.database).toBe(first.auth.database);
134 expect((yield* SDK.getNeonAuthEmailProvider(request)).type).toBe(
135 "shared",
136 );
137 const refused = yield* stack.deploy(program(undefined, true)).pipe(
138 Effect.as(false),
139 Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)),
140 );
141 expect(refused).toBe(true);
142 expect((yield* SDK.getNeonAuth(request)).name).toBe("Equivalent Auth");
143 expect(
144 (yield* SDK.getNeonAuthAllowLocalhost(request)).allow_localhost,
145 ).toBe(false);
146 yield* stack.destroy();
147 expect(
148 yield* SDK.getNeonAuth(request).pipe(
149 Effect.as(false),
150 Effect.catchTag("NotFound", () => Effect.succeed(true)),
151 ),
152 ).toBe(true);
153 yield* stack.destroy();
154 }),
155 {
156 tags: [
157 "provider:neon",
158 "provider:neon:auth",
159 "provider:neon:project",
160 "live",
161 ],
162 timeout: 120_000,
163 },
164);
165
166test.provider(
167 "managed Auth signup, signin, signout and trusted-origin rejection",
168 (stack) =>
169 Effect.gen(function* () {
170 yield* stack.destroy();
171 const { auth, project } = yield* stack.deploy(
172 application(false, "https://auth.example.com"),
173 );
174 const http = yield* HttpClient.HttpClient;
175 const baseUrl = auth.baseUrl.replace(/\/$/, "");
176 const authPath = baseUrl.endsWith("/auth") ? baseUrl : `${baseUrl}/auth`;
177 const email = `alchemy-${project.projectId}@example.com`;
178 const credentials = {
179 email,
180 password: "Alchemy-Managed-Auth-Test-Only-73!",
181 name: "Alchemy Test",
182 };
183 const post = (
184 path: string,
185 body: object,
186 origin = "https://auth.example.com",
187 cookie?: string,
188 ) =>
189 http.execute(
190 HttpClientRequest.post(`${authPath}/${path}`).pipe(
191 HttpClientRequest.bodyJsonUnsafe(body),
192 HttpClientRequest.setHeaders({
193 origin,
194 ...(cookie ? { cookie } : {}),
195 }),
196 ),
197 );
198 const denied = yield* post(
199 "sign-up/email",
200 credentials,
201 "https://untrusted.example.com",
202 );
203 expect(denied.status).toBe(403);
204 const signup = yield* post("sign-up/email", credentials);
205 expect(signup.status).toBe(200);
206 const signin = yield* post("sign-in/email", credentials);
207 expect(signin.status).toBe(200);
208 const cookies = signin.headers["set-cookie"];
209 expect(cookies).toBeDefined();
210 const cookie = cookies?.split(";")[0];
211 const token = yield* http.get(`${authPath}/token`, {
212 headers: { cookie: cookie ?? "" },
213 });
214 expect(token.status).toBe(200);
215 const jwt = yield* token.json.pipe(
216 Effect.flatMap(
217 Schema.decodeUnknownEffect(Schema.Struct({ token: Schema.String })),
218 ),
219 );
220 const payloadJson = yield* Effect.sync(() =>
221 Buffer.from(jwt.token.split(".")[1], "base64url").toString("utf8"),
222 );
223 const payload = yield* Schema.decodeEffect(
224 Schema.fromJsonString(
225 Schema.Struct({
226 iss: Schema.String,
227 sub: Schema.String,
228 exp: Schema.Number,
229 }),
230 ),
231 )(payloadJson);
232 expect(payload.iss).toBe(new URL(auth.baseUrl).origin);
233 expect(payload.sub.length).toBeGreaterThan(0);
234 expect(payload.exp).toBeGreaterThan(
235 yield* Effect.sync(() => Date.now() / 1000),
236 );
237 expect((yield* http.get(auth.jwksUrl)).status).toBe(200);
238 const signout = yield* post(
239 "sign-out",
240 {},
241 "https://auth.example.com",
242 cookie,
243 );
244 expect(signout.status).toBe(200);
245 const revoked = yield* http.get(`${authPath}/token`, {
246 headers: { cookie: cookie ?? "" },
247 });
248 expect(revoked.status).toBe(401);
249 const wrong = yield* post("sign-in/email", {
250 email,
251 password: "wrong-password",
252 });
253 expect(wrong.status).toBe(401);
254 yield* stack.destroy();
255 }),
256 {
257 tags: [
258 "provider:neon",
259 "provider:neon:auth",
260 "provider:neon:authtrusteddomain",
261 "provider:neon:branch",
262 "provider:neon:project",
263 "live",
264 ],
265 timeout: 120_000,
266 },
267);