| 1 | import { RuntimeContext } from "alchemy"; |
| 2 | import { APIError } from "better-auth/api"; |
| 3 | import { anonymous } from "better-auth/plugins/anonymous"; |
| 4 | import { describe, expect, it } from "alchemy-test"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import { |
| 9 | BetterAuth, |
| 10 | BetterAuthApiError, |
| 11 | isAPIErrorLike, |
| 12 | Memory, |
| 13 | mergeAPIErrorHeaders, |
| 14 | } from "@/index.ts"; |
| 15 | |
| 16 | const baseOptions = { |
| 17 | baseURL: "http://localhost:3000", |
| 18 | emailAndPassword: { enabled: true }, |
| 19 | secret: "test-secret-test-secret-test-secret", |
| 20 | } as const; |
| 21 | |
| 22 | /** Serve one web Request through the instance's `fetch` HttpEffect. */ |
| 23 | const serve = ( |
| 24 | fetch: Effect.Effect< |
| 25 | HttpServerResponse.HttpServerResponse, |
| 26 | unknown, |
| 27 | | RuntimeContext |
| 28 | | HttpServerRequest.HttpServerRequest |
| 29 | | import("effect/Scope").Scope |
| 30 | >, |
| 31 | request: Request, |
| 32 | ) => |
| 33 | fetch.pipe( |
| 34 | Effect.provideService( |
| 35 | HttpServerRequest.HttpServerRequest, |
| 36 | HttpServerRequest.fromWeb(request), |
| 37 | ), |
| 38 | Effect.flatMap((response) => |
| 39 | Effect.sync(() => HttpServerResponse.toWeb(response)), |
| 40 | ), |
| 41 | Effect.orDie, |
| 42 | ); |
| 43 | |
| 44 | const provideTestEnv = <A, E, R>( |
| 45 | effect: Effect.Effect<A, E, R>, |
| 46 | ): Effect.Effect<A, E, Exclude<R, RuntimeContext>> => |
| 47 | effect.pipe(Effect.provide(RuntimeContext.phantom)) as Effect.Effect< |
| 48 | A, |
| 49 | E, |
| 50 | Exclude<R, RuntimeContext> |
| 51 | >; |
| 52 | |
| 53 | describe("BetterAuth (memory)", { tags: ["unit", "local"] }, () => { |
| 54 | it.live("signs up, signs in over HTTP, reads the session", () => |
| 55 | Effect.gen(function* () { |
| 56 | const auth = yield* BetterAuth({ |
| 57 | ...baseOptions, |
| 58 | basePath: "/auth", |
| 59 | }); |
| 60 | |
| 61 | // sign up through the effectified api proxy |
| 62 | const signUp = yield* auth.api.signUpEmail({ |
| 63 | body: { |
| 64 | email: "user@example.com", |
| 65 | password: "password1234", |
| 66 | name: "Test User", |
| 67 | }, |
| 68 | }); |
| 69 | expect(signUp.user.email).toBe("user@example.com"); |
| 70 | |
| 71 | // sign in over the HTTP surface to capture the session cookie |
| 72 | const signInResponse = yield* serve( |
| 73 | auth.fetch, |
| 74 | new Request("http://localhost:3000/auth/sign-in/email", { |
| 75 | method: "POST", |
| 76 | headers: { |
| 77 | "content-type": "application/json", |
| 78 | "x-forwarded-for": "192.0.2.1", |
| 79 | }, |
| 80 | body: JSON.stringify({ |
| 81 | email: "user@example.com", |
| 82 | password: "password1234", |
| 83 | }), |
| 84 | }), |
| 85 | ); |
| 86 | expect(signInResponse.status).toBe(200); |
| 87 | const setCookies = signInResponse.headers.getSetCookie(); |
| 88 | expect(setCookies.length).toBeGreaterThan(0); |
| 89 | const cookie = setCookies.map((c) => c.split(";")[0]).join("; "); |
| 90 | |
| 91 | // read the session back with explicit headers |
| 92 | const session = yield* auth.getSession(new Headers({ cookie })); |
| 93 | expect(session).not.toBeNull(); |
| 94 | expect(session!.user.email).toBe("user@example.com"); |
| 95 | |
| 96 | // and via the ambient request overload |
| 97 | const ambient = yield* auth.getSession().pipe( |
| 98 | Effect.provideService( |
| 99 | HttpServerRequest.HttpServerRequest, |
| 100 | HttpServerRequest.fromWeb( |
| 101 | new Request("http://localhost:3000/anything", { |
| 102 | headers: { cookie }, |
| 103 | }), |
| 104 | ), |
| 105 | ), |
| 106 | ); |
| 107 | expect(ambient?.user.email).toBe("user@example.com"); |
| 108 | }).pipe(Effect.provide(Memory()), provideTestEnv), |
| 109 | ); |
| 110 | |
| 111 | it.live("maps APIError to BetterAuthApiError with typed fields", () => |
| 112 | Effect.gen(function* () { |
| 113 | const auth = yield* BetterAuth(baseOptions); |
| 114 | yield* auth.api.signUpEmail({ |
| 115 | body: { |
| 116 | email: "wrong-pass@example.com", |
| 117 | password: "password1234", |
| 118 | name: "Wrong Pass", |
| 119 | }, |
| 120 | }); |
| 121 | const error = yield* auth.api |
| 122 | .signInEmail({ |
| 123 | body: { |
| 124 | email: "wrong-pass@example.com", |
| 125 | password: "not-the-password", |
| 126 | }, |
| 127 | }) |
| 128 | .pipe(Effect.flip); |
| 129 | expect(error).toBeInstanceOf(BetterAuthApiError); |
| 130 | expect(error._tag).toBe("BetterAuthApiError"); |
| 131 | expect(error.statusCode).toBe(401); |
| 132 | expect(error.status).toBe("UNAUTHORIZED"); |
| 133 | expect(error.body?.code).toBeDefined(); |
| 134 | expect(error.headers).toBeInstanceOf(Headers); |
| 135 | }).pipe(Effect.provide(Memory()), provideTestEnv), |
| 136 | ); |
| 137 | |
| 138 | it.live("plugin endpoints surface on the typed api proxy", () => |
| 139 | Effect.gen(function* () { |
| 140 | const auth = yield* BetterAuth({ |
| 141 | ...baseOptions, |
| 142 | plugins: [anonymous()], |
| 143 | }); |
| 144 | // `signInAnonymous` only exists because the anonymous() plugin is in |
| 145 | // the options — this call type-checks AND runs through the proxy. |
| 146 | const result = yield* auth.api.signInAnonymous({}); |
| 147 | expect(result?.user.isAnonymous).toBe(true); |
| 148 | }).pipe(Effect.provide(Memory()), provideTestEnv), |
| 149 | ); |
| 150 | |
| 151 | it("api proxy is not a thenable and caches wrappers", () => { |
| 152 | const program = Effect.gen(function* () { |
| 153 | const auth = yield* BetterAuth(baseOptions); |
| 154 | const api = auth.api as Record<string, unknown>; |
| 155 | expect(api.then).toBeUndefined(); |
| 156 | expect(api.getSession).toBe(api.getSession); |
| 157 | expect(typeof api.getSession).toBe("function"); |
| 158 | }).pipe(Effect.provide(Memory()), provideTestEnv, Effect.scoped); |
| 159 | return Effect.runPromise(program as Effect.Effect<void>); |
| 160 | }); |
| 161 | |
| 162 | it("mergeAPIErrorHeaders preserves symbol-carried set-cookie headers", () => { |
| 163 | const error = new APIError( |
| 164 | "UNAUTHORIZED", |
| 165 | { message: "nope", code: "INVALID_EMAIL_OR_PASSWORD" }, |
| 166 | { "x-visible": "yes" }, |
| 167 | ); |
| 168 | const hidden = new Headers(); |
| 169 | hidden.append("set-cookie", "a=1; Path=/"); |
| 170 | hidden.append("set-cookie", "b=2; Path=/"); |
| 171 | hidden.set("x-hidden", "also"); |
| 172 | (error as unknown as Record<symbol, unknown>)[ |
| 173 | Symbol.for("better-call:api-error-headers") |
| 174 | ] = hidden; |
| 175 | |
| 176 | expect(isAPIErrorLike(error)).toBe(true); |
| 177 | const merged = mergeAPIErrorHeaders(error); |
| 178 | expect(merged.get("x-visible")).toBe("yes"); |
| 179 | expect(merged.get("x-hidden")).toBe("also"); |
| 180 | expect(merged.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); |
| 181 | |
| 182 | const wrapped = BetterAuthApiError.fromAPIError(error); |
| 183 | expect(wrapped.statusCode).toBe(401); |
| 184 | expect(wrapped.headers.getSetCookie()).toEqual([ |
| 185 | "a=1; Path=/", |
| 186 | "b=2; Path=/", |
| 187 | ]); |
| 188 | }); |
| 189 | |
| 190 | it("isAPIErrorLike rejects non-APIError values", () => { |
| 191 | expect(isAPIErrorLike(new Error("plain"))).toBe(false); |
| 192 | expect(isAPIErrorLike({ name: "APIError", statusCode: 401 })).toBe(false); |
| 193 | expect(isAPIErrorLike(null)).toBe(false); |
| 194 | }); |
| 195 | }); |