1/**
2 * The example, end to end: a user signs up with Better Auth, creates a
3 * repository through the app's API, mints an API key, and pushes with it.
4 * A second user can read the public repository but cannot push to it, and
5 * an unknown key is anonymous. Run with `bun test` from this directory.
6 */
7import * as Cloudflare from "alchemy/Cloudflare";
8import * as Test from "alchemy/Test/Bun";
9import { expect } from "bun:test";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import Stack from "../alchemy.run.ts";
15
16const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
17 providers: Cloudflare.providers(),
18 state: Cloudflare.state(),
19 stage: "test",
20});
21
22const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
23
24afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
25 timeout: 600_000,
26});
27
28interface JsonResponse {
29 readonly status: number;
30 readonly json: any;
31 readonly cookie: string;
32}
33
34/** A JSON request that carries and captures the session cookie. */
35const call = (
36 url: string,
37 path: string,
38 options: {
39 readonly method?: string;
40 readonly body?: unknown;
41 readonly cookie?: string;
42 } = {},
43) =>
44 Effect.tryPromise(async (signal): Promise<JsonResponse> => {
45 const response = await fetch(`${url}${path}`, {
46 method: options.method ?? (options.body === undefined ? "GET" : "POST"),
47 signal,
48 headers: {
49 // Browsers send Origin on every request; Better Auth requires it on
50 // cookie-authenticated writes (CSRF).
51 origin: url,
52 ...(options.body !== undefined
53 ? { "content-type": "application/json" }
54 : {}),
55 ...(options.cookie ? { cookie: options.cookie } : {}),
56 },
57 body:
58 options.body !== undefined ? JSON.stringify(options.body) : undefined,
59 });
60 const text = await response.text();
61 let json: unknown = null;
62 try {
63 json = text.length === 0 ? null : JSON.parse(text);
64 } catch {
65 json = text;
66 }
67 const cookie = response.headers
68 .getSetCookie()
69 .map((c) => c.split(";")[0]!)
70 .join("; ");
71 return {
72 status: response.status,
73 json,
74 cookie: cookie || (options.cookie ?? ""),
75 };
76 }).pipe(
77 // A fresh workers.dev rollout may answer before the request reaches the app.
78 // Retry only Cloudflare's own missing-route response, including for POSTs.
79 Effect.repeat({
80 while: (response) =>
81 typeof response.json === "string" &&
82 ((response.status === 404 &&
83 response.json.includes("<title>Page not found</title>")) ||
84 response.json.trim() === "error code: 1042"),
85 schedule: Schedule.spaced("2 seconds"),
86 times: 5,
87 }),
88 );
89
90/** Sign a user up (or in, when the account survived a NO_DESTROY run) and return the session cookie. */
91const signIn = Effect.fn(function* (
92 url: string,
93 user: { name: string; email: string; password: string },
94) {
95 const up = yield* call(url, "/api/auth/sign-up/email", { body: user });
96 const session =
97 up.status === 200
98 ? up
99 : yield* call(url, "/api/auth/sign-in/email", {
100 body: { email: user.email, password: user.password },
101 });
102 expect(session.status).toBe(200);
103 // Owner names are lowercased by the host; a Better Auth id is mixed-case.
104 return {
105 cookie: session.cookie,
106 id: (session.json.user.id as string).toLowerCase(),
107 };
108});
109
110class GitError extends Error {
111 constructor(
112 readonly args: string[],
113 readonly code: number,
114 readonly stderr: string,
115 ) {
116 super(`git ${args.join(" ")} exited ${code}: ${stderr}`);
117 }
118}
119
120/** Run git with a hermetic identity; fails with the exit code and stderr. */
121const git = (cwd: string, ...args: string[]) =>
122 Effect.tryPromise({
123 try: async () => {
124 const proc = Bun.spawn(["git", ...args], {
125 cwd,
126 stdout: "pipe",
127 stderr: "pipe",
128 env: {
129 ...process.env,
130 GIT_TERMINAL_PROMPT: "0",
131 GIT_CONFIG_NOSYSTEM: "1",
132 GIT_TRACE_CURL: process.env.GIT_TRACE_CURL ?? "",
133 GIT_AUTHOR_NAME: "integ",
134 GIT_AUTHOR_EMAIL: "integ@example.com",
135 GIT_COMMITTER_NAME: "integ",
136 GIT_COMMITTER_EMAIL: "integ@example.com",
137 },
138 });
139 const [stdout, stderr, code] = await Promise.all([
140 new Response(proc.stdout).text(),
141 new Response(proc.stderr).text(),
142 proc.exited,
143 ]);
144 if (code !== 0) throw new GitError(args, code, stderr);
145 return stdout;
146 },
147 catch: (cause) =>
148 cause instanceof GitError ? cause : new GitError(args, -1, String(cause)),
149 });
150
151const remoteWith = (url: string, key: string, owner: string, name: string) =>
152 url.replace("://", `://x:${key}@`) + `/${owner}/${name}.git`;
153
154test(
155 "sign up, create a repository, push with an API key; a stranger reads but cannot push",
156 Effect.gen(function* () {
157 const { webUrl } = yield* stack;
158 const url = webUrl.replace(/\/+$/, "");
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161
162 // The front door forwards /api/** to the host; wait for it to answer
163 // (anonymous listing is the middleware's 401).
164 yield* call(url, "/api/v1/repos").pipe(
165 Effect.filterOrFail(
166 (r) => r.status === 200 || r.status === 401,
167 () => new Error("not ready"),
168 ),
169 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 60 }),
170 );
171
172 const owner = yield* signIn(url, {
173 name: "Dana",
174 email: "dana@example.com",
175 password: "correct-horse-battery",
176 });
177
178 // Create a public repository under the owner's id. A previous run
179 // (NO_DESTROY) may have left one: delete it and wait for the purge.
180 const name = "web";
181 yield* call(url, `/api/v1/repos/${owner.id}/${name}`, {
182 method: "DELETE",
183 cookie: owner.cookie,
184 });
185 yield* call(url, `/api/v1/repos/${owner.id}/${name}`, {
186 cookie: owner.cookie,
187 }).pipe(
188 Effect.filterOrFail(
189 (r) => r.status === 404,
190 () => new Error("draining"),
191 ),
192 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 60 }),
193 );
194 const created = yield* call(url, "/api/v1/repos", {
195 body: { owner: owner.id, name, public: true },
196 cookie: owner.cookie,
197 });
198 expect(created.status).toBe(200);
199 expect(created.json.repo.owner).toBe(owner.id);
200
201 // Mint an API key: the password of the git remote.
202 const minted = yield* call(url, "/api/auth/api-key/create", {
203 body: { name: "laptop" },
204 cookie: owner.cookie,
205 });
206 expect(minted.status, JSON.stringify(minted.json)).toBe(200);
207 const key: string = minted.json.key;
208 expect(key.length).toBeGreaterThan(10);
209
210 // Push with it.
211 const tmp = yield* fs.makeTempDirectory({ prefix: "git-example-" });
212 const work = path.join(tmp, "work");
213 yield* fs.makeDirectory(work);
214 yield* git(work, "init", "-q", "-b", "main");
215 yield* fs.writeFileString(path.join(work, "README.md"), "# hello\n");
216 yield* git(work, "add", "-A");
217 yield* git(work, "commit", "-qm", "first");
218 yield* git(
219 work,
220 "push",
221 "-q",
222 remoteWith(url, key, owner.id, name),
223 "main",
224 );
225
226 // The owner's session sees the branch through the API.
227 const refs = yield* call(url, `/api/v1/repos/${owner.id}/${name}/refs`, {
228 cookie: owner.cookie,
229 });
230 expect(refs.status).toBe(200);
231 expect(
232 refs.json.refs.some(
233 (r: { name: string }) => r.name === "refs/heads/main",
234 ),
235 ).toBe(true);
236
237 // Anonymous clone of a public repository needs no credential.
238 const anon = path.join(tmp, "anon");
239 yield* git(tmp, "clone", "-q", `${url}/${owner.id}/${name}.git`, "anon");
240 yield* git(anon, "fsck", "--strict");
241
242 // A stranger: reads, cannot push.
243 const stranger = yield* signIn(url, {
244 name: "Alex",
245 email: "alex@example.com",
246 password: "correct-horse-battery",
247 });
248 const strangerKey = yield* call(url, "/api/auth/api-key/create", {
249 body: { name: "laptop" },
250 cookie: stranger.cookie,
251 });
252 yield* fs.writeFileString(path.join(anon, "README.md"), "# nope\n");
253 yield* git(anon, "commit", "-qam", "nope");
254 const denied = yield* git(
255 anon,
256 "push",
257 "-q",
258 remoteWith(url, strangerKey.json.key, owner.id, name),
259 "main",
260 ).pipe(Effect.result);
261 expect(denied._tag).toBe("Failure");
262
263 // An unknown key is anonymous: the private setting turns reads off too.
264 yield* call(url, `/api/v1/repos/${owner.id}/${name}`, {
265 method: "PATCH",
266 body: { public: false },
267 cookie: owner.cookie,
268 });
269 const hidden = yield* call(url, `/api/v1/repos/${owner.id}/${name}`);
270 expect(hidden.status).toBe(401);
271 // A private repository answers 401 to anyone but its owner, signed
272 // in or not, so its existence is not confirmed.
273 const strangerRead = yield* call(url, `/api/v1/repos/${owner.id}/${name}`, {
274 cookie: stranger.cookie,
275 });
276 expect(strangerRead.status).toBe(401);
277 }),
278 { timeout: 300_000 },
279);