400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
// 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<string, string>) => {
|
|
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<string, { build?: { args?: unknown }; environment?: Record<string, string> }> }
|
|
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)
|
|
})
|
|
})
|