Skip to content
Source
Sign inSign up

Auth.test.ts

229 lines8.5 KB

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