492 lines
24 KiB
TypeScript
492 lines
24 KiB
TypeScript
// 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([])
|
|
})
|
|
})
|