// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The Convex Better Auth adapter of this app (AUTH_PROVIDER=convex-better-auth, joint contract ยง14), // over fake transports: the deployment contract, the sign-out (derived service sessions first, then // Better Auth, then every cookie), the account's end and the deletion's Better Auth step bound in the // web and the worker, the relay's account events, the form's own password rule, and the locally // installed component schema with the admin plugin's fields, which is // generated from the app's Better Auth options. A real Better Auth session on the platform's pinned // self-hosted Convex runtime, in a browser, is e2e/better-auth (gate J-auth); this file proves no // live integration. `kit template:prune --auth oidc` removes it. import { readFileSync } from "node:fs" import { loadAppEnv, platformClients } from "@app/config" import { appModules } from "@app/modules" import { ACCOUNT_IDENTITY_PROVIDER } from "@app/modules/account" import { buildRegistry, Container } from "@conusai-kit/core" import { createMemoryDataSource, entitiesOf } from "@conusai-kit/data" import { requireAppUser, tenancyModule } from "@conusai-kit/tenancy" import { startFakePlatform } from "@conusai-kit/testing/fake-platform" import { convexAdapter } from "@convex-dev/better-auth" import { describe, expect, it, vi } from "vitest" import { parse } from "yaml" import { bindModulePorts } from "../apps/worker/src/ports.js" import { betterAuthOptions, MIN_PASSWORD_LENGTH as OPTIONS_MIN } from "../better-auth/functions/betterAuth/options" // The composition root is not under test here; better-auth.ts reaches it only to build its singleton. vi.mock("../apps/web/src/server/app", () => ({ webRuntime: () => { throw new Error("not in this test") }, sessionRevoked: async () => undefined, })) const { betterAuthEnv, betterAuthFormLabels, bindBetterAuthAccountEnd, createBetterAuthRuntime, deliverAccountEvent, MIN_PASSWORD_LENGTH, } = await import("../apps/web/src/server/better-auth") const ENV = { BETTER_AUTH_CONVEX_URL: "http://cloud.test", BETTER_AUTH_CONVEX_SITE_URL: "http://site.test/", BETTER_AUTH_CONVEX_ADMIN_KEY: "admin-key-of-the-test", BETTER_AUTH_RELAY_INTERVAL_MS: "60000", CONUSAI_PUBLIC_URL: "http://app.test", } const config = { provider: "convex-better-auth" as const, identityNamespace: "better-auth:test" } describe("the Better Auth deployment contract", () => { it("reads the deployment's URLs, its admin key and the relay interval", () => { expect(betterAuthEnv(ENV)).toEqual({ url: "http://cloud.test", siteUrl: "http://site.test", adminKey: "admin-key-of-the-test", relayIntervalMs: 60000, }) expect(betterAuthEnv({ ...ENV, BETTER_AUTH_RELAY_INTERVAL_MS: "" }).relayIntervalMs).toBe(2000) }) it("refuses a missing or malformed variable by name, never by value", () => { for (const [name, value, message] of [ ["BETTER_AUTH_CONVEX_URL", "", /BETTER_AUTH_CONVEX_URL is not set/], ["BETTER_AUTH_CONVEX_SITE_URL", "site.test", /BETTER_AUTH_CONVEX_SITE_URL is not an absolute/], ["BETTER_AUTH_CONVEX_ADMIN_KEY", " ", /BETTER_AUTH_CONVEX_ADMIN_KEY is not set/], ["BETTER_AUTH_RELAY_INTERVAL_MS", "10", /BETTER_AUTH_RELAY_INTERVAL_MS/], ] as const) { expect(() => betterAuthEnv({ ...ENV, [name]: value }), name).toThrow(message) } expect(() => betterAuthEnv({ ...ENV, BETTER_AUTH_CONVEX_ADMIN_KEY: "" })).not.toThrow(/admin-key-of-the-test/) }) }) type Seen = { url: string; init: RequestInit | undefined } /** Better Auth's token endpoint, the validation query, the sign-out and the admin mutation, faked. */ function fakeDeployment() { const seen: Seen[] = [] const fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url seen.push({ url, init }) if (url === "http://site.test/api/auth/convex/token") return Response.json({ token: "convex-token" }) if (url === "http://site.test/api/auth/sign-out") return Response.json({ success: true }) if (url.startsWith("http://cloud.test/api/query")) { const body = JSON.parse(String(init?.body)) as { path: string } if (body.path === "conusaiAuth:pendingRevocations") return Response.json({ status: "success", value: [], logLines: [] }) const value = { userId: "ba-user", sessionId: "ba-session", expiresAt: Date.now() + 3_600_000, createdAt: Date.now() - 1000, email: "ada@example.test", name: "Ada", emailVerified: false, } return Response.json({ status: "success", value, logLines: [] }) } if (url.startsWith("http://cloud.test/api/mutation")) return Response.json({ status: "success", value: null, logLines: [] }) throw new Error(`unexpected ${url}`) }) as typeof globalThis.fetch return { fetch, seen } } function runtimeWith(onSessionRevoked = vi.fn(async () => undefined)) { const registry = buildRegistry([tenancyModule]) const identities = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") }) const deployment = fakeDeployment() const warnings: string[] = [] const runtime = createBetterAuthRuntime({ config, identities, onSessionRevoked, onAccountEvent: async () => undefined, log: { warn: (message) => warnings.push(message) }, env: ENV, fetch: deployment.fetch, }) return { runtime, deployment, onSessionRevoked, warnings } } const signedIn = (cookie = "better-auth.session_token=tok.sig") => new Request("http://app.test/auth/sign-out", { method: "POST", headers: { cookie } }) describe("the Better Auth sign-out", () => { it("ends the device's derived service sessions, then the session at Better Auth, then every cookie", async () => { const { runtime, deployment, onSessionRevoked } = runtimeWith() try { const response = await runtime.signOut(signedIn()) expect(onSessionRevoked).toHaveBeenCalledWith( { provider: "convex-better-auth", opaqueId: "ba-session" }, "sign-out", ) const signOut = deployment.seen.findIndex((call) => call.url === "http://site.test/api/auth/sign-out") expect(signOut).toBeGreaterThan(0) expect(new Headers(deployment.seen[signOut]?.init?.headers).get("origin")).toBe("http://app.test") expect(response.status).toBe(303) expect(response.headers.get("location")).toBe("http://app.test/auth/signed-out") expect(response.headers.getSetCookie()).toContain( "better-auth.session_token=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax", ) } finally { await runtime.stop() } }) it("clears the cookies of a browser whose session Better Auth cannot confirm, and says so", async () => { const { runtime, onSessionRevoked, warnings } = runtimeWith() try { const response = await runtime.signOut(signedIn("")) expect(onSessionRevoked).not.toHaveBeenCalled() expect(response.status).toBe(303) expect(response.headers.getSetCookie().length).toBeGreaterThan(0) expect(warnings).toEqual([]) } finally { await runtime.stop() } }) it("ends a Better Auth account with the deployment's admin key, by its Better Auth user ID", async () => { const { runtime, deployment } = runtimeWith() try { await runtime.endAccount("ba-user") const call = deployment.seen.find((candidate) => candidate.url.startsWith("http://cloud.test/api/mutation")) expect(JSON.parse(String(call?.init?.body))).toMatchObject({ path: "conusaiAuth:endAccount", args: [{ userId: "ba-user" }], }) expect(new Headers(call?.init?.headers).get("authorization")).toBe("Convex admin-key-of-the-test") } finally { await runtime.stop() } }) }) describe("the account deletion's Better Auth step, bound in every process that may run it", () => { const BOUND = { AUTH_PROVIDER: "convex-better-auth", AUTH_IDENTITY_NAMESPACE: "better-auth:test", BETTER_AUTH_CONVEX_URL: "http://cloud.test", BETTER_AUTH_CONVEX_ADMIN_KEY: "admin-key-of-the-test", } const providerOf = (container: Container) => container.has(ACCOUNT_IDENTITY_PROVIDER) ? container.resolve(ACCOUNT_IDENTITY_PROVIDER) : null it("the web process binds the provider the ledger records, under the namespace; an OIDC app binds none", () => { const web = new Container() bindBetterAuthAccountEnd(web, BOUND) expect([providerOf(web)?.id, providerOf(web)?.issuer]).toEqual(["convex-better-auth", "better-auth:test"]) const oidc = new Container() bindBetterAuthAccountEnd(oidc, { ...BOUND, AUTH_PROVIDER: "oidc" }) expect(providerOf(oidc)).toBeNull() }) it("the worker binds the same for its sweep, and refuses to start without the deployment's admin key", async () => { const platform = await startFakePlatform() try { const env = { NODE_ENV: "test", CONUSAI_FAKE_PLATFORM: "1", CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "test-token", CONUSAI_PROJECT_ID: "42", CONUSAI_ORG_ID: "42", CONUSAI_ORG_SLUG: "owner-org", POSTGRES_URL: "postgresql://app_app:secret@db:5432/app", POSTGRES_READER_URL: "postgresql://app_reader:secret@db:5432/app", } const registry = buildRegistry([...appModules]) const clients = platformClients(loadAppEnv("worker", env), registry, env) // The worker reads its process environment, as it does in a container. const bound = (extra: Record) => { for (const [name, value] of Object.entries(extra)) vi.stubEnv(name, value) try { const container = new Container() bindModulePorts(container, clients, registry) return container } finally { vi.unstubAllEnvs() } } const worker = bound(BOUND) expect([providerOf(worker)?.id, providerOf(worker)?.issuer]).toEqual(["convex-better-auth", "better-auth:test"]) expect(() => bound({ ...BOUND, BETTER_AUTH_CONVEX_ADMIN_KEY: "" })).toThrow("BETTER_AUTH_CONVEX_ADMIN_KEY") expect(providerOf(bound({ AUTH_PROVIDER: "" }))).toBeNull() } finally { await platform.close() } }) }) describe("the Better Auth deployment's credentials stay server-side (no platform link injects them)", () => { type Compose = { services: Record }> } const compose = (path: string) => parse(readFileSync(new URL(`../${path}`, import.meta.url), "utf8")) as Compose const holders = (file: Compose, name: string) => Object.entries(file.services) .filter(([, service]) => service.environment?.[name] !== undefined) .map(([id]) => id) .sort() it("names the admin key only in the server processes that use it, each from the deployment's own variable", () => { const postgres = compose("compose.yaml") const convex = compose("profiles/convex/compose.yaml") // The web server (session checks, the relay), the worker (the sweep ends a deleted account), the // job that deploys the Better Auth functions; on Convex the deploy job also installs it in the // app's backend, whose account actions end the account there. expect(holders(postgres, "BETTER_AUTH_CONVEX_ADMIN_KEY")).toEqual(["migrate", "web", "worker"]) expect(holders(convex, "BETTER_AUTH_CONVEX_ADMIN_KEY")).toEqual(["convex-deploy", "web"]) // The secret: the deploy job alone. expect(holders(postgres, "BETTER_AUTH_SECRET")).toEqual(["migrate"]) expect(holders(convex, "BETTER_AUTH_SECRET")).toEqual(["convex-deploy"]) for (const file of [postgres, convex]) { for (const [id, service] of Object.entries(file.services)) { for (const [name, value] of Object.entries(service.environment ?? {})) { if (!name.startsWith("BETTER_AUTH_")) continue // Passed through from the variable of the same name, defaulting to empty: never a literal. expect(value, `${id}: ${name}`).toBe(`\${${name}:-}`) } expect(JSON.stringify(service.build?.args ?? {}), `${id} builds with no Better Auth value`).not.toContain( "BETTER_AUTH", ) expect(Object.keys(service.environment ?? {}).filter((name) => name.startsWith("NEXT_PUBLIC_"))).toEqual([]) } } const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8") expect(dockerfile).not.toContain("BETTER_AUTH") const example = readFileSync(new URL("../.env.example", import.meta.url), "utf8") expect(example).not.toMatch(/NEXT_PUBLIC_[A-Z_]*BETTER_AUTH|NEXT_PUBLIC_[A-Z_]*CONVEX/) }) it("links the Better Auth deployment only as the named better-auth Convex link, never beside Identity", () => { // A link role decides the injected names (BL): the default Convex link injects // CONVEX_SELF_HOSTED_*, the better-auth one BETTER_AUTH_CONVEX_* as runtime-only variables. // `kit template:prune --auth convex-better-auth` swaps the Identity link for it; an app that // keeps OIDC declares none (docs/how-to/build-an-app-on-the-template.md). for (const path of ["conusai.json", "profiles/convex/conusai.json"]) { const config = JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), "utf8")) as { links?: Array<{ type: string; role?: string }> } const links = config.links ?? [] const roles = links.filter((link) => link.type === "convex").map((link) => link.role ?? "default") expect(new Set(roles).size, `${path}: one Convex link per role`).toBe(roles.length) const betterAuth = links.filter((link) => link.role === "better-auth") expect( betterAuth.every((link) => link.type === "convex"), path, ).toBe(true) if (betterAuth.length > 0) { expect( links.some((link) => link.type === "identity"), `${path}: no Identity link beside Better Auth`, ).toBe(false) } } }) }) describe("Better Auth's account events", () => { it("go to the account module's flow as the mapped app user; nothing for a subject never mapped", async () => { const delivered: unknown[] = [] const ports = { accountFlow: async (event: unknown) => void delivered.push(event) } const event = { reason: "user-disabled", identityNamespace: "better-auth:test", providerSubject: "ba-user", } as const await deliverAccountEvent(ports, { ...event, kitUserId: null }) expect(delivered).toEqual([]) await deliverAccountEvent(ports, { ...event, kitUserId: "app-user" }) await deliverAccountEvent(ports, { ...event, reason: "account-deleted", kitUserId: "app-user" }) expect(delivered).toEqual([ { reason: "user-disabled", userId: "app-user" }, { reason: "account-deleted", userId: "app-user" }, ]) }) it("throw the flow's failure, so the relay keeps the event and delivers it again", async () => { const ports = { accountFlow: async () => { throw new Error("backend unavailable") }, } await expect( deliverAccountEvent(ports, { reason: "account-deleted", identityNamespace: "better-auth:test", providerSubject: "ba-user", kitUserId: "app-user", }), ).rejects.toThrow("backend unavailable") }) }) describe("the sign-in form", () => { it("checks the password length Better Auth enforces, and labels every error from a fixed set", () => { expect(MIN_PASSWORD_LENGTH).toBe(OPTIONS_MIN) const labels = betterAuthFormLabels( { message: (key, values) => `${key}${values ? JSON.stringify(values) : ""}` }, "sign-up", ) expect(labels.passwordHelp).toBe(`signin.account.passwordHelp{"min":${MIN_PASSWORD_LENGTH}}`) expect(Object.keys(labels.errors).sort()).toEqual([ "disabled", "exists", "failed", "invalid", "rateLimited", "unavailable", ]) }) }) describe("the locally installed Better Auth component", () => { it("is the schema the app's Better Auth options generate, with the admin plugin's fields", async () => { const options = { database: convexAdapter({} as never, {} as never), ...betterAuthOptions({ providers: [{ applicationID: "convex", domain: "" }] }), } const adapter = ( options.database as unknown as (all: unknown) => { createSchema(all: unknown, file: string): Promise<{ code: string }> } )(options) const { code } = await adapter.createSchema(options, "better-auth/functions/betterAuth/schema.ts") const body = code.slice(code.indexOf("import ")) const header = `// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // Generated from ./options.ts by the @convex-dev/better-auth schema generator: do not edit it by hand. // After a change of the Better Auth options, regenerate it with // pnpm exec vitest run test/better-auth.test.ts -u // (the "local install" of @convex-dev/better-auth, which is how the admin plugin's fields get here). ` for (const field of ["role:", "banned:", "banReason:", "banExpires:", "impersonatedBy:"]) expect(body).toContain(field) await expect(`${header}${body}`).toMatchFileSnapshot("../better-auth/functions/betterAuth/schema.ts") }) it("maps one Better Auth user to one app user, under the configured namespace and never by e-mail", async () => { const registry = buildRegistry([tenancyModule]) const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") }) const first = await requireAppUser(data, { issuer: config.identityNamespace, subject: "ba-user", email: "ada@example.test", displayName: "Ada", locale: "en", }) const other = await requireAppUser(data, { issuer: config.identityNamespace, subject: "ba-other", email: "ada@example.test", displayName: "Ada", locale: "en", }) expect(other.id).not.toBe(first.id) }) })