This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Gate J-auth (joint contract §14) in a browser: real Better Auth sessions on the platform's pinned
|
||||
// self-hosted Convex backend, a production build of this app (`next start`), headless Chromium.
|
||||
//
|
||||
// - sign-up and sign-in through the app's own pages; reload; a second device without a second login
|
||||
// - server rendering of a signed-in page, with and without JavaScript, and client hydration
|
||||
// - Support's widget-session mint and Guides' bootstrap resolving the one stable app user
|
||||
// - sign-out of one device ending only its derived sessions; a revocation made at Better Auth
|
||||
// itself reaching them through the relay; an administrator's disablement (admin plugin) refusing
|
||||
// the user; Better Auth's account deletion and the app's own both erasing through the account flow
|
||||
// - a Better Auth outage as a typed unavailable answer, never a signed-out or another person
|
||||
// - network and CSP inspection: one origin, no policy violation, no server secret and no bearer
|
||||
// in anything the browser received
|
||||
//
|
||||
// The fake platform answers the platform API (Support, Guides); everything about the sign-in is real.
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { type Browser, type BrowserContext, expect, type Page, test } from "@playwright/test"
|
||||
import { call, callRaw, makeOrganization, unique, violations } from "../support/app"
|
||||
import { APP_ROOT, type JauthStack } from "./stack"
|
||||
|
||||
const stack = (): JauthStack => JSON.parse(process.env.JAUTH_STACK ?? "{}") as JauthStack
|
||||
const PASSWORD = "correct horse battery staple"
|
||||
|
||||
/** Everything the browsers of this run received, for the inspection at the end. */
|
||||
const seen = {
|
||||
requests: [] as string[],
|
||||
bodies: [] as Array<{ url: string; text: string }>,
|
||||
headers: [] as Array<{ url: string; headers: Record<string, string> }>,
|
||||
setCookies: [] as string[],
|
||||
pageErrors: [] as string[],
|
||||
}
|
||||
|
||||
async function device(browser: Browser, index: number): Promise<{ context: BrowserContext; page: Page }> {
|
||||
// A distinct client address per device, as the platform's ingress would send it (Better Auth keys
|
||||
// its rate limits by it).
|
||||
const context = await browser.newContext({ extraHTTPHeaders: { "x-forwarded-for": `10.20.0.${index}` } })
|
||||
await context.addInitScript(() => {
|
||||
const target = globalThis as unknown as { __csp?: string[] }
|
||||
target.__csp = []
|
||||
document.addEventListener("securitypolicyviolation", (event) =>
|
||||
target.__csp?.push(`${event.violatedDirective} ${event.blockedURI}`),
|
||||
)
|
||||
})
|
||||
context.on("request", (request) => seen.requests.push(request.url()))
|
||||
context.on("response", async (response) => {
|
||||
const headers = await response.allHeaders().catch(() => ({}) as Record<string, string>)
|
||||
seen.headers.push({ url: response.url(), headers })
|
||||
for (const header of await response.headersArray().catch(() => [])) {
|
||||
if (header.name.toLowerCase() === "set-cookie") seen.setCookies.push(header.value)
|
||||
}
|
||||
const type = headers["content-type"] ?? ""
|
||||
if (/json|html|javascript|text|event-stream/.test(type) && !type.includes("event-stream")) {
|
||||
const text = await response.text().catch(() => "")
|
||||
seen.bodies.push({ url: response.url(), text })
|
||||
}
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on("pageerror", (error) => seen.pageErrors.push(error.message))
|
||||
return { context, page }
|
||||
}
|
||||
|
||||
async function cspViolations(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => (globalThis as unknown as { __csp?: string[] }).__csp ?? [])
|
||||
}
|
||||
|
||||
/** The fake platform's record of the Support and Guides calls the app made. */
|
||||
async function platform(): Promise<{
|
||||
support: Array<Record<string, unknown>>
|
||||
guides: Array<Record<string, unknown>>
|
||||
}> {
|
||||
const response = await fetch(`${stack().platformUrl}/__e2e/state`)
|
||||
return (await response.json()) as { support: Array<Record<string, unknown>>; guides: Array<Record<string, unknown>> }
|
||||
}
|
||||
|
||||
async function eventually<T>(
|
||||
label: string,
|
||||
probe: () => Promise<T | undefined>,
|
||||
ms = 15_000,
|
||||
): Promise<{ value: T; after: number }> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
const value = await probe()
|
||||
if (value !== undefined) return { value, after: Date.now() - started }
|
||||
if (Date.now() - started > ms) throw new Error(`j-auth: ${label} did not happen within ${ms} ms`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
const signedIn = (page: Page) => expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1)
|
||||
|
||||
/** Signs up through the app's sign-up page, and lands signed in. */
|
||||
async function signUp(page: Page, name: string, email: string): Promise<void> {
|
||||
await page.goto("/auth/sign-up")
|
||||
await page.getByLabel("Name").fill(name)
|
||||
await page.getByLabel("E-mail address").fill(email)
|
||||
await page.getByLabel("Password").fill(PASSWORD)
|
||||
const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-up/email"))
|
||||
await page.getByRole("button", { name: "Create the account" }).click()
|
||||
expect((await answer).status()).toBe(200)
|
||||
await page.waitForURL((url) => url.pathname === "/" || url.pathname === "/onboarding")
|
||||
await signedIn(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in made the way Better Auth's own client makes it, from inside the page: answers the Better
|
||||
* Auth user the answer names, and proves the answer carried no bearer.
|
||||
*/
|
||||
async function betterAuthUserOf(page: Page, email: string): Promise<string> {
|
||||
const answer = await authPost(page, "/sign-in/email", { email, password: PASSWORD })
|
||||
expect(answer.status).toBe(200)
|
||||
const body = JSON.parse(answer.body) as { user: { id: string }; token?: unknown }
|
||||
// Better Auth's session token is in the HTTP-only cookie only: the proxy removed it from the body.
|
||||
expect(body.token).toBeUndefined()
|
||||
expect(answer.body).not.toMatch(/"token"\s*:/)
|
||||
// That sign-in replaced the page's session: the page's CSRF token is the new session's after a reload.
|
||||
await page.reload()
|
||||
await signedIn(page)
|
||||
return body.user.id
|
||||
}
|
||||
|
||||
async function signInPage(page: Page, email: string, password = PASSWORD): Promise<number> {
|
||||
await page.goto("/auth/sign-in")
|
||||
await page.getByLabel("E-mail address").fill(email)
|
||||
await page.getByLabel("Password").fill(password)
|
||||
const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-in/email"))
|
||||
await page.locator("form").getByRole("button", { name: "Sign in", exact: true }).click()
|
||||
return (await answer).status()
|
||||
}
|
||||
|
||||
/** A procedure call as the page's own forms make it; answers the status, for a refusal. */
|
||||
async function statusOf(page: Page, procedure: string, input: unknown): Promise<number> {
|
||||
const csrf =
|
||||
(await page
|
||||
.locator('meta[name="csrf-token"]')
|
||||
.getAttribute("content")
|
||||
.catch(() => null)) ?? ""
|
||||
return (await callRaw(page.request, csrf, procedure, input)).status()
|
||||
}
|
||||
|
||||
/** A POST to the app's /api/auth from inside the page, as Better Auth's own client would make it. */
|
||||
const authPost = (page: Page, path: string, body: unknown = {}) =>
|
||||
page.evaluate(
|
||||
async ({ path, body }) => {
|
||||
const response = await fetch(`/api/auth${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return { status: response.status, body: await response.text() }
|
||||
},
|
||||
{ path, body },
|
||||
)
|
||||
|
||||
const sessionKey = (mint: Record<string, unknown>) => String((mint.body as Record<string, unknown>).identity_session)
|
||||
const subjectOf = (mint: Record<string, unknown>) => String((mint.body as Record<string, unknown>).subject)
|
||||
const mints = async () => (await platform()).support.filter((entry) => entry.route === "mint")
|
||||
|
||||
// The Support and Guides rows below use these; an app pruned of both modules keeps the helpers unused.
|
||||
void [platform, eventually, sessionKey, subjectOf, mints]
|
||||
|
||||
test.describe
|
||||
.serial("J-auth: Better Auth on the self-hosted Convex runtime, in a browser", () => {
|
||||
const ada = { email: `${unique("ada")}@example.test`, name: "Ada Lovelace" }
|
||||
let a: { context: BrowserContext; page: Page }
|
||||
let b: { context: BrowserContext; page: Page }
|
||||
let org = ""
|
||||
let adaSubject = ""
|
||||
|
||||
test("sign-up, server rendering, hydration and reload", async ({ browser }) => {
|
||||
a = await device(browser, 1)
|
||||
const { page, context } = a
|
||||
// Signed out: a protected page sends the visitor to the sign-in page, which offers Better Auth's form.
|
||||
await page.goto("/settings/account")
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in/)
|
||||
await expect(page.getByLabel("Password")).toBeVisible()
|
||||
|
||||
await signUp(page, ada.name, ada.email)
|
||||
// A sign-in answer names the user and carries no bearer (Better Auth's own body has its token).
|
||||
await betterAuthUserOf(page, ada.email)
|
||||
// The one cookie Better Auth leaves in the browser: its HTTP-only session token.
|
||||
const cookies = await context.cookies()
|
||||
const betterAuth = cookies.filter((cookie) => cookie.name.includes("better-auth"))
|
||||
expect(betterAuth.map((cookie) => cookie.name)).toEqual(["better-auth.session_token"])
|
||||
expect(betterAuth[0]?.httpOnly).toBe(true)
|
||||
|
||||
// Server rendering: the signed-in page's HTML names the person before any script runs...
|
||||
const html = await (await page.request.get("/settings/account")).text()
|
||||
expect(html).toContain(ada.email)
|
||||
// ...and with JavaScript off entirely, the same session renders the same person.
|
||||
const noScript = await browser.newContext({
|
||||
javaScriptEnabled: false,
|
||||
storageState: await context.storageState(),
|
||||
})
|
||||
const plain = await noScript.newPage()
|
||||
await plain.goto("/settings/account")
|
||||
await expect(plain.getByText(ada.email, { exact: true })).toBeVisible()
|
||||
await noScript.close()
|
||||
|
||||
// Hydration: the client controls work (a control that is server HTML only would not toggle).
|
||||
await page.goto("/settings/account")
|
||||
const root = page.locator("html")
|
||||
await page.getByRole("button", { name: "Theme: Dark", exact: true }).click()
|
||||
await expect(root).toHaveClass("light")
|
||||
await page.reload()
|
||||
await expect(root).toHaveClass("light")
|
||||
await expect(page.getByText(ada.email, { exact: true })).toBeVisible()
|
||||
await signedIn(page)
|
||||
expect(await violations(page)).toEqual([])
|
||||
expect(await cspViolations(page)).toEqual([])
|
||||
})
|
||||
|
||||
test("Support and Guides resolve one stable app user, on a second device without a second login", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const workspace = await makeOrganization(a.page, "jauth")
|
||||
org = workspace.org
|
||||
expect(org).toBeTruthy()
|
||||
adaSubject = (await call<{ userId: string }>(a.page, "tenancy.me.get", undefined)).userId
|
||||
expect(adaSubject).toBeTruthy()
|
||||
// The shell's own modules ask for the help surface and the guides of a project page.
|
||||
const config = a.page.waitForResponse((response) => response.url().endsWith("/api/v1/support.widget.config")) // kit:support
|
||||
// kit:guides:begin
|
||||
const bootstrap = a.page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/v1/guides.runtime.bootstrap"),
|
||||
)
|
||||
// kit:guides:end
|
||||
await a.page.goto(`/${workspace.org}/${workspace.workspace}/${workspace.project}`)
|
||||
expect(await (await config).json()).toEqual({ mode: "platform", channel: "spk_jauth" }) // kit:support
|
||||
expect((await bootstrap).status()).toBe(200) // kit:guides
|
||||
// kit:support:begin
|
||||
|
||||
const first = (await call<{ token: string }>(a.page, "support.widgetSession.mint", { org })).token
|
||||
expect(first).toMatch(/^sws_/)
|
||||
// kit:support:end
|
||||
|
||||
b = await device(browser, 2)
|
||||
expect(await signInPage(b.page, ada.email)).toBe(200)
|
||||
await b.page.waitForURL((url) => !url.pathname.startsWith("/auth/"))
|
||||
await signedIn(b.page)
|
||||
// kit:support:begin
|
||||
await call(b.page, "support.widgetSession.mint", { org })
|
||||
const [mintA, mintB] = await mints()
|
||||
expect(mintA && mintB).toBeTruthy()
|
||||
expect(subjectOf(mintA as Record<string, unknown>)).toBe(adaSubject)
|
||||
// The same app user on both devices, each with its own identity session (one device, one sign-in).
|
||||
expect(subjectOf(mintB as Record<string, unknown>)).toBe(adaSubject)
|
||||
expect(sessionKey(mintA as Record<string, unknown>)).toMatch(/^18:convex-better-auth:/)
|
||||
expect(sessionKey(mintB as Record<string, unknown>)).not.toBe(sessionKey(mintA as Record<string, unknown>))
|
||||
expect(((mintA as Record<string, unknown>).body as Record<string, unknown>).app_tenant).toBeTruthy()
|
||||
// kit:support:end
|
||||
// kit:guides:begin
|
||||
await call(b.page, "guides.runtime.bootstrap", { org, path: "/" })
|
||||
const boots = (await platform()).guides.filter((entry) => entry.route === "bootstrap")
|
||||
expect(boots.length).toBeGreaterThanOrEqual(2)
|
||||
for (const boot of boots) expect(JSON.stringify(boot)).toContain(adaSubject)
|
||||
// kit:guides:end
|
||||
})
|
||||
|
||||
test("signing out one device ends that device's derived sessions only", async ({ browser }) => {
|
||||
const before = await a.context.storageState()
|
||||
const [mintA] = await mints() // kit:support
|
||||
await a.page.goto("/")
|
||||
await a.page.getByRole("button", { name: "Sign out", exact: true }).click()
|
||||
await expect(a.page).toHaveURL(/\/auth\/signed-out$/)
|
||||
expect((await a.context.cookies()).filter((cookie) => cookie.name.includes("better-auth"))).toEqual([])
|
||||
// kit:support:begin
|
||||
const revoked = await eventually("the sign-out's revocation", async () =>
|
||||
(await platform()).support.find(
|
||||
(entry) =>
|
||||
entry.route === "revoke" &&
|
||||
(entry.body as Record<string, unknown>).identity_session === sessionKey(mintA as Record<string, unknown>),
|
||||
),
|
||||
)
|
||||
expect(revoked.value.revoked).toBe(1)
|
||||
// kit:support:end
|
||||
// The cookie the device held is dead at Better Auth, not just deleted from the browser.
|
||||
const replay = await browser.newContext({ storageState: before })
|
||||
const response = await replay.request.post(`/api/v1/tenancy.me.get`, {
|
||||
headers: { "sec-fetch-site": "same-origin" },
|
||||
})
|
||||
expect(response.status()).toBe(401)
|
||||
await replay.close()
|
||||
// The other device is untouched.
|
||||
await b.page.reload()
|
||||
await signedIn(b.page)
|
||||
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
|
||||
})
|
||||
|
||||
test("a revocation made at Better Auth reaches the derived sessions through the relay", async ({ browser }) => {
|
||||
const c = await device(browser, 3)
|
||||
expect(await signInPage(c.page, ada.email)).toBe(200)
|
||||
await signedIn(c.page)
|
||||
// kit:support:begin
|
||||
await call(c.page, "support.widgetSession.mint", { org })
|
||||
const mintC = (await mints()).at(-1) as Record<string, unknown>
|
||||
// kit:support:end
|
||||
// Device B signs every other device out, at Better Auth (its own endpoint, through the proxy).
|
||||
const revokedAt = Date.now() // kit:support
|
||||
const revokeOthers = await authPost(b.page, "/revoke-other-sessions")
|
||||
expect(revokeOthers.status).toBe(200)
|
||||
expect(await statusOf(c.page, "tenancy.me.get", undefined)).toBe(401)
|
||||
// kit:support:begin
|
||||
await eventually("the relay's revocation of device C", async () =>
|
||||
(await platform()).support.find(
|
||||
(entry) =>
|
||||
entry.route === "revoke" && (entry.body as Record<string, unknown>).identity_session === sessionKey(mintC),
|
||||
),
|
||||
)
|
||||
const propagation = Date.now() - revokedAt
|
||||
console.info(
|
||||
`j-auth: a revocation at Better Auth reached Support within ${propagation} ms (relay interval 250 ms)`,
|
||||
)
|
||||
expect(propagation).toBeLessThan(10_000)
|
||||
// kit:support:end
|
||||
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
|
||||
await c.context.close()
|
||||
})
|
||||
|
||||
test("an administrator's disablement (admin plugin) refuses the user and ends their sessions", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const grace = { email: `${unique("grace")}@example.test`, name: "Grace Hopper" }
|
||||
const g = await device(browser, 4)
|
||||
await signUp(g.page, grace.name, grace.email)
|
||||
const graceId = await betterAuthUserOf(g.page, grace.email)
|
||||
const graceOrg = (await makeOrganization(g.page, "grace")).org // kit:support
|
||||
const graceSubject = (await call<{ userId: string }>(g.page, "tenancy.me.get", undefined)).userId
|
||||
expect(graceSubject).not.toBe(adaSubject)
|
||||
await call(g.page, "support.widgetSession.mint", { org: graceOrg }) // kit:support
|
||||
|
||||
// The proxy never hands a browser the admin plugin's routes.
|
||||
expect((await authPost(g.page, "/admin/ban-user", { userId: graceId })).status).toBe(404)
|
||||
|
||||
// An administrator (BETTER_AUTH_ADMIN_USER_IDS of the Better Auth deployment) bans her at Better Auth.
|
||||
const operator = await fetch(`${stack().baseUrl}/api/auth/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: stack().baseUrl, "x-forwarded-for": "10.20.0.99" },
|
||||
body: JSON.stringify({ name: "Operator", email: `${unique("operator")}@example.test`, password: PASSWORD }),
|
||||
})
|
||||
expect(operator.status).toBe(200)
|
||||
const operatorId = ((await operator.json()) as { user: { id: string } }).user.id
|
||||
const operatorCookie = operator.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => cookie.split(";")[0])
|
||||
.find((cookie) => cookie?.startsWith("better-auth.session_token="))
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(APP_ROOT, "node_modules/convex/bin/main.js"), "env", "set", "BETTER_AUTH_ADMIN_USER_IDS", operatorId],
|
||||
{
|
||||
cwd: join(APP_ROOT, "better-auth"),
|
||||
env: {
|
||||
...process.env,
|
||||
CONVEX_SELF_HOSTED_URL: stack().convexUrl,
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY: stack().adminKey,
|
||||
},
|
||||
stdio: "ignore",
|
||||
},
|
||||
)
|
||||
const ban = await fetch(`${stack().convexSiteUrl}/api/auth/admin/ban-user`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: stack().baseUrl, cookie: operatorCookie ?? "" },
|
||||
body: JSON.stringify({ userId: graceId, banReason: "j-auth" }),
|
||||
})
|
||||
expect(ban.status, await ban.text()).toBe(200)
|
||||
|
||||
// Her session is gone at once: the app's next request is signed out.
|
||||
expect(await statusOf(g.page, "tenancy.me.get", undefined)).toBe(401)
|
||||
// kit:support:begin
|
||||
// The relay hands the disablement to the account flow, which revokes every derived session of hers.
|
||||
await eventually("the disablement's revocation", async () =>
|
||||
(await platform()).support.find(
|
||||
(entry) => entry.route === "revoke" && (entry.body as Record<string, unknown>).subject === graceSubject,
|
||||
),
|
||||
)
|
||||
// kit:support:end
|
||||
// And she cannot sign in again: Better Auth refuses, and the page says so in her language.
|
||||
expect(await signInPage(g.page, grace.email)).toBe(403)
|
||||
await expect(g.page.locator("form").getByRole("alert")).toHaveText(
|
||||
"This account is disabled. Ask the people who run this app.",
|
||||
)
|
||||
await g.context.close()
|
||||
})
|
||||
|
||||
test("Better Auth's own account deletion erases the person through the account flow", async ({ browser }) => {
|
||||
const linus = { email: `${unique("linus")}@example.test`, name: "Linus" }
|
||||
const l = await device(browser, 5)
|
||||
await signUp(l.page, linus.name, linus.email)
|
||||
const linusOrg = (await makeOrganization(l.page, "linus")).org
|
||||
const linusSubject = (await call<{ userId: string }>(l.page, "tenancy.me.get", undefined)).userId
|
||||
expect(linusSubject).not.toBe(adaSubject)
|
||||
await call(l.page, "support.widgetSession.mint", { org: linusOrg }) // kit:support
|
||||
const deleted = await authPost(l.page, "/delete-user")
|
||||
expect(deleted.status, deleted.body).toBe(200)
|
||||
expect(await statusOf(l.page, "tenancy.me.get", undefined)).toBe(401)
|
||||
// kit:support:begin
|
||||
await eventually("the deletion's Support erasure", async () =>
|
||||
(await platform()).support.find((entry) => entry.route === "erase" && entry.subject === linusSubject),
|
||||
)
|
||||
// kit:support:end
|
||||
// kit:guides:begin
|
||||
await eventually("the deletion's Guides erasure", async () =>
|
||||
(await platform()).guides.find((entry) => entry.route === "erase" && entry.subject === linusSubject),
|
||||
)
|
||||
// kit:guides:end
|
||||
// No resurrection: the same address signs up again as a new person with nothing.
|
||||
await l.context.clearCookies()
|
||||
await signUp(l.page, linus.name, linus.email)
|
||||
await l.page.goto("/")
|
||||
await expect(l.page.getByText(`Org ${linusOrg}`)).toHaveCount(0)
|
||||
await l.context.close()
|
||||
})
|
||||
|
||||
test("the app's own account deletion ends the Better Auth account too", async ({ browser }) => {
|
||||
const mary = { email: `${unique("mary")}@example.test`, name: "Mary" }
|
||||
const m = await device(browser, 6)
|
||||
await signUp(m.page, mary.name, mary.email)
|
||||
await m.page.goto("/settings/account")
|
||||
const view = await call<{ state: string }>(m.page, "account.deletion.request", { confirm: mary.email })
|
||||
expect(view.state).toBe("completed")
|
||||
expect(await statusOf(m.page, "tenancy.me.get", undefined)).toBe(401)
|
||||
// The Better Auth account is gone with it: the password signs nobody in.
|
||||
expect(await signInPage(m.page, mary.email)).toBe(401)
|
||||
await m.context.close()
|
||||
})
|
||||
|
||||
test("a Better Auth outage is a typed unavailable answer, never signed out or somebody else", async () => {
|
||||
execFileSync("docker", ["pause", stack().backend])
|
||||
try {
|
||||
const response = await b.page.request.post("/api/v1/tenancy.me.get", {
|
||||
headers: { "sec-fetch-site": "same-origin" },
|
||||
})
|
||||
expect(response.status()).toBe(503)
|
||||
expect(((await response.json()) as { code?: string }).code).toBe("auth_provider_unavailable")
|
||||
} finally {
|
||||
execFileSync("docker", ["unpause", stack().backend])
|
||||
}
|
||||
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
|
||||
})
|
||||
|
||||
test("network and CSP: one origin, no violation, no server secret or bearer in the browser", async () => {
|
||||
const origin = new URL(stack().baseUrl).origin
|
||||
const foreign = seen.requests.filter(
|
||||
(url) => !url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:"),
|
||||
)
|
||||
expect(foreign).toEqual([])
|
||||
expect(await cspViolations(b.page)).toEqual([])
|
||||
expect(seen.pageErrors).toEqual([])
|
||||
const documents = seen.headers.filter((entry) => (entry.headers["content-type"] ?? "").includes("text/html"))
|
||||
expect(documents.length).toBeGreaterThan(5)
|
||||
for (const entry of documents) {
|
||||
const policy = entry.headers["content-security-policy"] ?? ""
|
||||
expect(policy, entry.url).toContain("connect-src 'self'")
|
||||
expect(policy, entry.url).toMatch(/script-src 'self' 'nonce-[^']+' 'strict-dynamic'/)
|
||||
expect(policy, entry.url).not.toContain("unsafe-eval")
|
||||
expect(policy, entry.url).not.toMatch(/\*/)
|
||||
}
|
||||
for (const secret of stack().secrets) {
|
||||
expect(seen.bodies.filter((body) => body.text.includes(secret)).map((body) => body.url)).toEqual([])
|
||||
expect(
|
||||
seen.headers.filter((entry) => JSON.stringify(entry.headers).includes(secret)).map((entry) => entry.url),
|
||||
).toEqual([])
|
||||
}
|
||||
// No bearer of Better Auth's: no token field from /api/auth, no cached Convex JWT cookie, no set-auth-token.
|
||||
for (const body of seen.bodies.filter((entry) => entry.url.includes("/api/auth/"))) {
|
||||
expect(body.text, body.url).not.toMatch(/"token"\s*:/)
|
||||
}
|
||||
expect(seen.setCookies.filter((cookie) => cookie.includes("convex_jwt") && !/Max-Age=0/i.test(cookie))).toEqual(
|
||||
[],
|
||||
)
|
||||
expect(seen.headers.filter((entry) => "set-auth-token" in entry.headers)).toEqual([])
|
||||
// The production build the browser loaded carries no server variable of Better Auth's.
|
||||
const statics = join(APP_ROOT, "apps", "web", ".next", "static")
|
||||
const files = (function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name)
|
||||
return statSync(path).isDirectory() ? walk(path) : [path]
|
||||
})
|
||||
})(statics)
|
||||
const leaking = files.filter((file) => {
|
||||
const text = readFileSync(file, "utf8")
|
||||
return (
|
||||
/BETTER_AUTH_CONVEX_ADMIN_KEY|BETTER_AUTH_SECRET|CONUSAI_API_TOKEN/.test(text) ||
|
||||
stack().secrets.some((secret) => text.includes(secret))
|
||||
)
|
||||
})
|
||||
expect(leaking).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user