| 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 | */ |
| 7 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 8 | import * as Test from "alchemy/Test/Bun"; |
| 9 | import { expect } from "bun:test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Path from "effect/Path"; |
| 14 | import Stack from "../alchemy.run.ts"; |
| 15 | |
| 16 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 17 | providers: Cloudflare.providers(), |
| 18 | state: Cloudflare.state(), |
| 19 | stage: "test", |
| 20 | }); |
| 21 | |
| 22 | const stack = beforeAll(deploy(Stack), { timeout: 600_000 }); |
| 23 | |
| 24 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { |
| 25 | timeout: 600_000, |
| 26 | }); |
| 27 | |
| 28 | interface 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. */ |
| 35 | const 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. */ |
| 91 | const 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 | |
| 110 | class 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. */ |
| 121 | const 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 | |
| 151 | const remoteWith = (url: string, key: string, owner: string, name: string) => |
| 152 | url.replace("://", `://x:${key}@`) + `/${owner}/${name}.git`; |
| 153 | |
| 154 | test( |
| 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 | ); |