This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The account page in a browser (joint contract §12): a person exports their data -- every installed
|
||||
// module's section in one file -- then deletes the account with the keyboard, and is signed out on
|
||||
// every device; the browser modules' stored state is gone, and the same address signs up again as
|
||||
// a new person with nothing. axe on each state the page shows.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { callRaw, makeOrganization, signIn, unique, violations } from "./support/app"
|
||||
|
||||
test("a person exports their data, then deletes the account and is signed out everywhere", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const email = `${unique("leaver")}@example.test`
|
||||
await signIn(page, email)
|
||||
const { org } = await makeOrganization(page, "leaver")
|
||||
// A second device of the same person.
|
||||
const elsewhere = await browser.newContext()
|
||||
const other = await elsewhere.newPage()
|
||||
await signIn(other, email)
|
||||
|
||||
// What the browser modules keep for a signed-in person, as their providers store it.
|
||||
await page.evaluate((slug) => {
|
||||
sessionStorage.setItem(`conusai.guides.${slug}.offline`, "{}") // kit:guides
|
||||
sessionStorage.setItem("conusai.support.session.v1:spk_e2e", "{}") // kit:support
|
||||
sessionStorage.setItem(`unrelated.${slug}`, "kept")
|
||||
}, org)
|
||||
|
||||
// The shell links to the page from every page.
|
||||
await page.goto("/")
|
||||
await page.getByRole("link", { name: "Account", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/settings\/account$/)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible()
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/You are the only member of Org leaver-/)).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// Export: one JSON file, every section named.
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("button", { name: "Export my data" }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toMatch(/^account-export-\d{4}-\d{2}-\d{2}\.json$/)
|
||||
const exported = JSON.parse(readFileSync(await file.path(), "utf8")) as {
|
||||
format: string
|
||||
complete: boolean
|
||||
sections: Record<string, { status: string; data: unknown }>
|
||||
}
|
||||
expect(exported.format).toBe("conusai.account-export.v1")
|
||||
expect(exported.complete).toBe(true)
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: { profile: { email }, organizations: [{ slug: org, role: "owner" }] },
|
||||
})
|
||||
// Offline (the fake platform), the services hold nothing of the person, and say so.
|
||||
expect(exported.sections.guides).toEqual({ status: "ok", data: null }) // kit:guides
|
||||
expect(exported.sections.support).toEqual({ status: "ok", data: null }) // kit:support
|
||||
await expect(page.getByRole("status").filter({ hasText: "Your export is downloaded." })).toBeVisible()
|
||||
|
||||
// A wrong address is refused, and nothing happens.
|
||||
const confirm = page.getByLabel("Type your e-mail address to confirm")
|
||||
await confirm.fill("somebody@example.test")
|
||||
await page.getByRole("button", { name: "Delete my account" }).click()
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "type the account's e-mail address to confirm" }),
|
||||
).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// The address, and Enter: the keyboard alone deletes the account.
|
||||
await confirm.fill(email)
|
||||
await confirm.press("Enter")
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
|
||||
).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// The browser modules' lifecycle handlers ran: their keys are gone, nobody else's.
|
||||
const left = await page.evaluate(() => Object.keys(sessionStorage).sort())
|
||||
expect(left).toEqual([`unrelated.${org}`])
|
||||
|
||||
// Signed out here, and on the other device.
|
||||
await page.goto("/settings/account")
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in/)
|
||||
await other.goto("/")
|
||||
await expect(other.getByRole("link", { name: "Sign in" }).first()).toBeVisible()
|
||||
// The other device's cookie, sent to the API: its session is revoked, so it is nobody.
|
||||
expect((await callRaw(other.request, "", "account.deletion.status", undefined)).status()).toBe(401)
|
||||
await elsewhere.close()
|
||||
|
||||
// The same address is a new person now, with no organization.
|
||||
await signIn(page, email)
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Organizations" })).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: `Org ${org}` })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Starts the J-auth stack once for the run and removes it after (containers, volumes, processes).
|
||||
|
||||
import { startJauthStack } from "./stack"
|
||||
|
||||
export default async function globalSetup(): Promise<() => Promise<void>> {
|
||||
const { stack, stop } = await startJauthStack()
|
||||
process.env.JAUTH_STACK = JSON.stringify(stack)
|
||||
return stop
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Gate J-auth in a browser (joint contract §14): headless Chromium against a production build of
|
||||
// this app signing in with Better Auth on a disposable self-hosted Convex backend (stack.ts).
|
||||
// pnpm run build && pnpm exec playwright test -c e2e/better-auth/playwright.config.ts
|
||||
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.JAUTH_WEB_PORT ?? 3150)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.e2e.ts",
|
||||
globalSetup: "./global-setup.ts",
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 20_000 },
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
browserName: "chromium",
|
||||
headless: true,
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The app as a production build with AUTH_PROVIDER=convex-better-auth, for gate J-auth (joint
|
||||
// contract §14): a disposable Better Auth deployment on the platform's pinned self-hosted Convex
|
||||
// image, a Postgres with the three roles, the fake platform of @conusai-kit/testing answering the
|
||||
// real platform API (the app runs in platform mode, as in production: Support mints widget
|
||||
// sessions and Guides bootstraps through it), the migrate job (which deploys the Better Auth
|
||||
// functions), the worker and `next start`. Containers publish on 127.0.0.1 only (rootless Docker),
|
||||
// and every container and volume is removed when the run ends.
|
||||
//
|
||||
// Run it with `pnpm run build` first, then
|
||||
// pnpm exec playwright test -c e2e/better-auth/playwright.config.ts
|
||||
|
||||
import { type ChildProcess, execFileSync, spawn } from "node:child_process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { mkdirSync, openSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
|
||||
export const APP_ROOT = new URL("../..", import.meta.url).pathname
|
||||
const LOGS = join(APP_ROOT, "e2e", ".logs")
|
||||
|
||||
/** The platform's pinned self-hosted Convex backend (images/third-party.toml `convex-backend`). */
|
||||
export const CONVEX_BACKEND_IMAGE =
|
||||
process.env.CONVEX_BACKEND_IMAGE ??
|
||||
"build.conusai.com/conusai/convex-backend@sha256:1738f1673f8d63161043a7859710d2301b1e9d6271e06afbb7af31594ea3a58f"
|
||||
|
||||
const WEB_PORT = Number(process.env.JAUTH_WEB_PORT ?? 3150)
|
||||
|
||||
/** What the specs read: the app, the fake platform and the Better Auth deployment's server side. */
|
||||
export interface JauthStack {
|
||||
readonly baseUrl: string
|
||||
readonly platformUrl: string
|
||||
readonly convexUrl: string
|
||||
readonly convexSiteUrl: string
|
||||
readonly adminKey: string
|
||||
readonly backend: string
|
||||
/** Server-side values a browser must never see: the network inspection looks for each. */
|
||||
readonly secrets: readonly string[]
|
||||
}
|
||||
|
||||
const sh = (command: string, args: string[], env?: NodeJS.ProcessEnv): string =>
|
||||
execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) }).trim()
|
||||
|
||||
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
|
||||
const deadline = Date.now() + seconds * 1000
|
||||
for (;;) {
|
||||
try {
|
||||
if (await check()) return
|
||||
} catch {
|
||||
// not yet
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`j-auth: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
|
||||
mkdirSync(LOGS, { recursive: true })
|
||||
const out = openSync(join(LOGS, `jauth-${name}.log`), "w")
|
||||
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
|
||||
}
|
||||
|
||||
function stopGroup(child: ChildProcess): void {
|
||||
if (child.pid === undefined) return
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM")
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
/** The host port Docker published for a container port, on 127.0.0.1. */
|
||||
const portOf = (container: string, port: number): number =>
|
||||
Number(
|
||||
sh("docker", ["port", container, `${port}/tcp`])
|
||||
.split("\n")[0]
|
||||
?.split(":")
|
||||
.at(-1),
|
||||
)
|
||||
|
||||
const removeContainer = (name: string): void => {
|
||||
try {
|
||||
// -v: the anonymous data volume goes with the container.
|
||||
sh("docker", ["rm", "-f", "-v", name])
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export async function startJauthStack(): Promise<{ stack: JauthStack; stop(): Promise<void> }> {
|
||||
const run = randomBytes(4).toString("hex")
|
||||
const backend = `jauth-better-auth-${run}`
|
||||
const postgres = `jauth-postgres-${run}`
|
||||
const processes: ChildProcess[] = []
|
||||
const platform = await startFakePlatform()
|
||||
const stop = async (): Promise<void> => {
|
||||
for (const child of processes) stopGroup(child)
|
||||
await platform.close()
|
||||
removeContainer(backend)
|
||||
removeContainer(postgres)
|
||||
}
|
||||
|
||||
try {
|
||||
sh("docker", [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
backend,
|
||||
"-l",
|
||||
"conusai.test=j-auth",
|
||||
"-e",
|
||||
`INSTANCE_NAME=jauth_${run}`,
|
||||
"-e",
|
||||
`INSTANCE_SECRET=${randomBytes(32).toString("hex")}`,
|
||||
// The backend reaches its own site URL (the issuer of Better Auth's Convex tokens) inside the container.
|
||||
"-e",
|
||||
"CONVEX_CLOUD_ORIGIN=http://127.0.0.1:3210",
|
||||
"-e",
|
||||
"CONVEX_SITE_ORIGIN=http://127.0.0.1:3211",
|
||||
"-p",
|
||||
"127.0.0.1::3210",
|
||||
"-p",
|
||||
"127.0.0.1::3211",
|
||||
CONVEX_BACKEND_IMAGE,
|
||||
])
|
||||
sh("docker", [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
postgres,
|
||||
"-l",
|
||||
"conusai.test=j-auth",
|
||||
"-p",
|
||||
"127.0.0.1::5432",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=appdev",
|
||||
"-v",
|
||||
`${join(APP_ROOT, "dev", "postgres-roles.sql")}:/docker-entrypoint-initdb.d/10-roles.sql:ro`,
|
||||
"pgvector/pgvector:pg18",
|
||||
])
|
||||
const convexUrl = `http://127.0.0.1:${portOf(backend, 3210)}`
|
||||
const convexSiteUrl = `http://127.0.0.1:${portOf(backend, 3211)}`
|
||||
const pgPort = portOf(postgres, 5432)
|
||||
const pg = (role: string) => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${pgPort}/appdev`
|
||||
await until("the Better Auth backend", async () => (await fetch(`${convexUrl}/version`)).ok)
|
||||
const adminKey = sh("docker", ["exec", backend, "./generate_admin_key.sh"]).split("\n").at(-1) ?? ""
|
||||
if (!adminKey) throw new Error("j-auth: the backend generated no admin key")
|
||||
await until("postgres", () => {
|
||||
sh("docker", ["exec", postgres, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
|
||||
return true
|
||||
})
|
||||
|
||||
const baseUrl = `http://localhost:${WEB_PORT}`
|
||||
const betterAuthSecret = randomBytes(32).toString("base64url")
|
||||
const platformToken = `jauth-platform-token-${randomBytes(8).toString("hex")}`
|
||||
const sessionSecret = randomBytes(32).toString("base64url")
|
||||
// A production run against the platform API (the fake platform answers it): no fake-platform
|
||||
// switch, no dev sign-in, no insecure cookies, no OIDC variable -- what a deployment has.
|
||||
const common: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
NODE_ENV: "production",
|
||||
CONUSAI_API_URL: `${platform.url}/api`,
|
||||
CONUSAI_API_TOKEN: platformToken,
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
CONUSAI_ORG_ID: "jauth",
|
||||
CONUSAI_ORG_SLUG: "jauth",
|
||||
CONUSAI_PUBLIC_URL: baseUrl,
|
||||
CONUSAI_RELEASE: "j-auth",
|
||||
CONUSAI_SUPPORT_CHANNEL: "spk_jauth", // kit:support
|
||||
POSTGRES_URL: pg("app"),
|
||||
POSTGRES_READER_URL: pg("reader"),
|
||||
KNOWLEDGE_VECTOR_STORE: "pgvector",
|
||||
AUTH_SESSION_SECRET: sessionSecret,
|
||||
AUTH_PROVIDER: "convex-better-auth",
|
||||
AUTH_IDENTITY_NAMESPACE: "better-auth:jauth",
|
||||
BETTER_AUTH_CONVEX_URL: convexUrl,
|
||||
BETTER_AUTH_CONVEX_SITE_URL: convexSiteUrl,
|
||||
BETTER_AUTH_CONVEX_ADMIN_KEY: adminKey,
|
||||
BETTER_AUTH_RELAY_INTERVAL_MS: "250",
|
||||
}
|
||||
|
||||
// The migrate job: the schema, then the Better Auth functions (it alone holds BETTER_AUTH_SECRET).
|
||||
execFileSync(process.execPath, ["apps/worker/dist/migrate.js"], {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...common, POSTGRES_MIGRATE_URL: pg("owner"), BETTER_AUTH_SECRET: betterAuthSecret },
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
// The deployed functions typecheck against the contexts Convex generated for them (deploy.mjs
|
||||
// pushes without a typecheck: the runtime image carries no TypeScript).
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(APP_ROOT, "node_modules/typescript/bin/tsc"), "-p", "better-auth/functions/tsconfig.json", "--noEmit"],
|
||||
{ cwd: APP_ROOT, stdio: "inherit" },
|
||||
)
|
||||
|
||||
processes.push(
|
||||
spawnLogged("worker", process.execPath, ["apps/worker/dist/main.js"], APP_ROOT, {
|
||||
...common,
|
||||
WORKER_HEARTBEAT_FILE: join(LOGS, "jauth-worker-heartbeat"),
|
||||
}),
|
||||
)
|
||||
processes.push(
|
||||
spawnLogged(
|
||||
"web",
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "start", "-p", String(WEB_PORT), "-H", "localhost"],
|
||||
join(APP_ROOT, "apps", "web"),
|
||||
common,
|
||||
),
|
||||
)
|
||||
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 120)
|
||||
await until(
|
||||
"the worker",
|
||||
() => readFileSync(join(LOGS, "jauth-worker.log"), "utf8").includes("worker: serving"),
|
||||
60,
|
||||
)
|
||||
|
||||
return {
|
||||
stack: {
|
||||
baseUrl,
|
||||
platformUrl: platform.url,
|
||||
convexUrl,
|
||||
convexSiteUrl,
|
||||
adminKey,
|
||||
backend,
|
||||
secrets: [adminKey, betterAuthSecret, platformToken, sessionSecret],
|
||||
},
|
||||
stop,
|
||||
}
|
||||
} catch (error) {
|
||||
await stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant logos in a browser (plan item AT2.6, spec §10.1, §10.3): one uploaded logo stands in for the
|
||||
// other theme, an image larger than a stored logo may be is resized and not refused, the organization's and
|
||||
// the app's share images are drawn, and a damaged PNG is refused with nothing stored.
|
||||
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { dimensions, markPng, noisePng } from "../test/support/images"
|
||||
import { makeOrganization, signIn, unique } from "./support/app"
|
||||
|
||||
test("one uploaded logo is used on both themes, a larger image is resized, and the share images are drawn", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
await signIn(page, `${unique("admin")}@example.test`)
|
||||
const { org } = await makeOrganization(page, "logos")
|
||||
|
||||
// only the dark theme's logo, and larger than a stored logo may be: it is resized, not refused
|
||||
await page.goto(`/${org}/branding`)
|
||||
const upload = noisePng(700, 300)
|
||||
expect(upload.length).toBeGreaterThan(256 * 1024)
|
||||
await page
|
||||
.getByLabel("Logo for the dark theme")
|
||||
.setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: upload })
|
||||
await page.getByRole("button", { name: "Save the brand" }).click()
|
||||
await expect(page.getByAltText("Current logo")).toHaveCount(1)
|
||||
// the page says what each theme shows
|
||||
await expect(page.getByText("Light theme: the dark logo, because this theme has none of its own")).toBeVisible()
|
||||
await expect(page.getByText("Dark theme: its own logo")).toBeVisible()
|
||||
|
||||
// the organization layout draws that logo for both themes, at 1x and 2x
|
||||
const html = await (await page.request.get(`/${org}`)).text()
|
||||
const source = `src="/${org}/brand/logo/dark\\?v=[0-9a-f]{16}&s=1x"`
|
||||
expect(html).toMatch(new RegExp(`class="app-logo app-logo-dark"[^>]*${source}`))
|
||||
expect(html).toMatch(new RegExp(`class="app-logo app-logo-light"[^>]*${source}`))
|
||||
// The page advertises the app's public share image: the organization's own (`/{org}/brand/og`)
|
||||
// answers a member only, so a crawler could never fetch it, and an organization stays
|
||||
// indistinguishable from a missing one to anyone outside it.
|
||||
expect(html).toMatch(/<meta property="og:image" content="http:\/\/127.0.0.1:\d+\/api\/brand\/og"/)
|
||||
expect(html).not.toContain(`/${org}/brand/og"`)
|
||||
|
||||
// the stored sizes are the standard ones, and nothing but a PNG is served
|
||||
const one = await page.request.get(`/${org}/brand/logo/dark?s=1x`)
|
||||
expect(one.headers()["content-type"]).toBe("image/png")
|
||||
expect(dimensions(await one.body()).width).toBeLessThanOrEqual(420)
|
||||
const two = await page.request.get(`/${org}/brand/logo/dark?s=2x`)
|
||||
expect(dimensions(await two.body()).width).toBeLessThanOrEqual(840)
|
||||
expect((await page.request.get(`/${org}/brand/logo/light`)).status()).toBe(404)
|
||||
|
||||
// the organization's share image: a member gets it, a stranger gets what a missing organization gets
|
||||
const card = await page.request.get(`/${org}/brand/og`)
|
||||
expect(card.status()).toBe(200)
|
||||
expect(card.headers()["content-type"]).toBe("image/png")
|
||||
expect(dimensions(await card.body())).toEqual({ width: 1200, height: 630 })
|
||||
const stranger = await browser.newPage()
|
||||
await signIn(stranger, `${unique("stranger")}@example.test`)
|
||||
expect((await stranger.request.get(`/${org}/brand/og`)).status()).toBe(404)
|
||||
await stranger.close()
|
||||
|
||||
// the app's own share image is public: a link preview is fetched without a session
|
||||
const anonymous = await browser.newContext()
|
||||
const appCard = await anonymous.request.get(`${new URL(page.url()).origin}/api/brand/og`)
|
||||
expect(appCard.status()).toBe(200)
|
||||
expect(dimensions(await appCard.body())).toEqual({ width: 1200, height: 630 })
|
||||
await anonymous.close()
|
||||
|
||||
// removing the logo removes it from the page and from the store
|
||||
await page.goto(`/${org}/branding`)
|
||||
await page.getByRole("button", { name: "Remove the dark logo" }).click()
|
||||
await expect(page.getByText("Dark theme: the app's logo, because no logo is set")).toBeVisible()
|
||||
// the page reloads itself once the removal is stored
|
||||
const reloaded = page.waitForEvent("load")
|
||||
await page.getByRole("button", { name: "Save the brand" }).click()
|
||||
await reloaded
|
||||
await expect(page.getByAltText("Current logo")).toHaveCount(0)
|
||||
expect((await page.request.get(`/${org}/brand/logo/dark`)).status()).toBe(404)
|
||||
})
|
||||
|
||||
test("a PNG that is damaged is refused and nothing is stored", async ({ page }) => {
|
||||
await signIn(page, `${unique("admin")}@example.test`)
|
||||
const { org } = await makeOrganization(page, "damaged")
|
||||
const png = Buffer.from(markPng(300, 80))
|
||||
png[30] = (png[30] ?? 0) ^ 0xff
|
||||
await page.goto(`/${org}/branding`)
|
||||
await page
|
||||
.getByLabel("Logo for the light theme")
|
||||
.setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: png })
|
||||
await page.getByRole("button", { name: "Save the brand" }).click()
|
||||
await expect(page.locator(".app-error")).toContainText("That did not work")
|
||||
await page.reload()
|
||||
await expect(page.getByAltText("Current logo")).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant brand in a browser (plan item AT2.6): an administrator chooses colours and a logo and a
|
||||
// member sees them, legibly in both themes, from the first byte; a member who is not an administrator
|
||||
// cannot change them; and a logo that could run script is refused.
|
||||
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { call, callRaw, makeOrganization, signIn, unique, violations } from "./support/app"
|
||||
|
||||
const channels = (color: string): [number, number, number] => {
|
||||
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color)
|
||||
if (!match) throw new Error(`not an rgb colour: ${color}`)
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
}
|
||||
const luminance = ([r, g, b]: [number, number, number]): number => {
|
||||
const lin = (v: number) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4)
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
}
|
||||
const contrast = (a: string, b: string): number => {
|
||||
const [hi, lo] = [luminance(channels(a)), luminance(channels(b))].sort((x, y) => y - x) as [number, number]
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
test("a tenant brand applies in both themes, from the first paint", async ({ page }) => {
|
||||
await signIn(page, `${unique("admin")}@example.test`)
|
||||
const { org } = await makeOrganization(page, "brand")
|
||||
|
||||
await page.goto(`/${org}/branding`)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Brand" })).toBeVisible()
|
||||
await page.getByLabel("Display name").fill("Acme Review")
|
||||
await page.getByLabel("Primary colour").fill("#1a1a8c")
|
||||
await page.getByLabel("Accent colour").fill("#f4c430")
|
||||
|
||||
// the preview answers before anything is saved, and says what the contrast is
|
||||
await expect(page.getByText(/Text on the colour: \d+\.\d to 1/).first()).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// Saving reloads the page (brand-form.tsx); wait for that reload, so a later navigation of the
|
||||
// test's own never races it ("interrupted by another navigation").
|
||||
const reloaded = page.waitForEvent("load")
|
||||
await page.getByRole("button", { name: "Save the brand" }).click()
|
||||
await reloaded
|
||||
await expect(page.getByLabel("Primary colour")).toHaveValue("#1a1a8c")
|
||||
|
||||
// server-rendered: the raw HTML of the organization page already carries the brand layer
|
||||
const html = await (await page.request.get(`/${org}`)).text()
|
||||
expect(html).toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--brand-accent:#1a1a8c/)
|
||||
expect(html).toContain("Acme Review")
|
||||
// No vendor name in what the tenant's users get. Asset URLs under /_next/ are left out: a dev
|
||||
// server names its chunks after the package they start in (".../_next/static/chunks/..._%40conusai-kit_brand_...js"),
|
||||
// while a production build hashes them.
|
||||
expect(html.replace(/\/_next\/[^"'\s\\]+/g, "").toLowerCase()).not.toContain("conusai")
|
||||
|
||||
// the primary action's fill is the brand's colour on both themes, and what is written as text on it reads
|
||||
await page.goto(`/${org}/branding`)
|
||||
const panels = page.locator(".brand-preview-panel")
|
||||
await expect(panels).toHaveCount(2)
|
||||
for (const [index, theme] of ["dark", "light"].entries()) {
|
||||
const panel = panels.nth(index)
|
||||
const action = panel.locator(".brand-preview-action")
|
||||
const fill = await action.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
const onFill = await action.evaluate((el) => getComputedStyle(el).color)
|
||||
expect(channels(fill), theme).toEqual([0x1a, 0x1a, 0x8c])
|
||||
expect(contrast(fill, onFill), `${theme} text on the fill`).toBeGreaterThanOrEqual(4.5)
|
||||
const link = panel.locator(".brand-preview-link")
|
||||
const ink = await link.evaluate((el) => getComputedStyle(el).color)
|
||||
const page_ = await panel.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
expect(contrast(ink, page_), `${theme} link on the page`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
// one element carries the theme class: the light preview, and nothing else on the page
|
||||
expect(await page.evaluate(() => document.querySelectorAll(".light").length)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("a member who is not an administrator reads the brand and cannot change it", async ({ page, browser }) => {
|
||||
const adminEmail = `${unique("admin")}@example.test`
|
||||
await signIn(page, adminEmail)
|
||||
const { org } = await makeOrganization(page, "perm")
|
||||
const memberEmail = `${unique("member")}@example.test`
|
||||
const invite = await call<{ token: string }>(page, "tenancy.member.invite", {
|
||||
org,
|
||||
email: memberEmail,
|
||||
role: "member",
|
||||
})
|
||||
|
||||
const member = await browser.newPage()
|
||||
await signIn(member, memberEmail)
|
||||
await call(member, "tenancy.invitation.accept", { org, token: invite.token })
|
||||
await member.goto(`/${org}/branding`)
|
||||
await expect(member.getByText("Only administrators can change the brand.")).toBeVisible()
|
||||
await expect(member.getByRole("button", { name: "Save the brand" })).toHaveCount(0)
|
||||
|
||||
// and the procedure answers them as it answers a stranger
|
||||
const csrf = (await member.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
|
||||
const refused = await callRaw(member.request, csrf, "branding.brand.set", { org, primary: "#000000" })
|
||||
expect(refused.status()).toBe(404)
|
||||
await member.close()
|
||||
})
|
||||
|
||||
test("a logo that carries a script is refused, and nothing is stored", async ({ page }) => {
|
||||
await signIn(page, `${unique("admin")}@example.test`)
|
||||
const { org } = await makeOrganization(page, "logo")
|
||||
await page.goto(`/${org}/branding`)
|
||||
await page.getByLabel("Logo for the light theme").setInputFiles({
|
||||
name: "logo.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
|
||||
})
|
||||
await page.getByRole("button", { name: "Save the brand" }).click()
|
||||
await expect(page.locator(".app-error")).toContainText("That did not work")
|
||||
await page.reload()
|
||||
await expect(page.getByAltText("Current logo")).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The document-review flow in a browser (plan item AT2.3): upload a PDF, the worker reads it through the
|
||||
// fake platform's OCR job API, the four labelled lines of the fixture become proposals (its drawing page
|
||||
// is reported as not read), a person approves, edits and rejects, and the Excel export holds the approved
|
||||
// lines and only those.
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sheetRows } from "../modules/documents/__tests__/support/unzip"
|
||||
import { call, makeOrganization, signIn, unique, violations } from "./support/app"
|
||||
|
||||
const PDF = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
|
||||
|
||||
test("a fixture PDF becomes lines a person reviews, and only approved lines are exported", async ({ page }) => {
|
||||
await signIn(page, `${unique("reviewer")}@example.test`)
|
||||
const { org, workspace, project } = await makeOrganization(page)
|
||||
const base = `/${org}/${workspace}/${project}/documents`
|
||||
|
||||
await page.goto(base)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Documents" })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// upload through the file input of the form: the request answers before any OCR has run
|
||||
await page
|
||||
.getByLabel("Upload a document")
|
||||
.setInputFiles({ name: "report.pdf", mimeType: "application/pdf", buffer: PDF })
|
||||
await page.getByRole("button", { name: "Upload", exact: true }).click()
|
||||
// an upload is three requests (begin, the piece, complete), and the page reloads itself when the
|
||||
// last has answered, with the file in the list: wait for that, or the navigation below would cut
|
||||
// the upload off in the middle
|
||||
await expect(page.getByRole("row", { name: /report\.pdf/ })).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// the worker picks the file up, submits it to the OCR job API and proposes the lines
|
||||
await expect(async () => {
|
||||
await page.goto(base)
|
||||
await expect(page.getByRole("row", { name: /report\.pdf.*Ready for review/ })).toBeVisible({ timeout: 2_000 })
|
||||
}).toPass({ timeout: 60_000, intervals: [1_000] })
|
||||
|
||||
await page.getByRole("link", { name: "Review report.pdf" }).click()
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText("report.pdf")
|
||||
// what was proposed by a machine says so, in text
|
||||
await expect(page.getByText("AI-generated content")).toBeVisible()
|
||||
await expect(page.getByRole("row")).toHaveCount(5) // header and four lines
|
||||
// the fixture's third page is a drawing: the extraction left it out, and the page says so
|
||||
await expect(page.getByRole("heading", { name: "Pages not read" })).toBeVisible()
|
||||
await expect(page.getByText("Page 3: drawing. It was left out of the extraction.")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Export approved lines (Excel)" })).toBeDisabled()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// each line shows where it was read
|
||||
await expect(page.getByRole("complementary", { name: "Source, page 2" }).first()).toContainText(
|
||||
"Total amount: 1 250,00 EUR",
|
||||
)
|
||||
|
||||
// approve one, correct another, reject a third
|
||||
await page.getByRole("button", { name: "Approve Reference" }).click()
|
||||
await expect(page.getByRole("row", { name: /Reference.*Approved/ })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Edit Issued on" }).click()
|
||||
await page.getByLabel("Label").fill("Issue date")
|
||||
await page.getByLabel("Value").fill("2026-04-02")
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByRole("row", { name: /Issue date.*Edited/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Approve Issue date" }).click()
|
||||
await expect(page.getByRole("row", { name: /Issue date.*Approved/ })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Reject Signed by" }).click()
|
||||
await expect(page.getByRole("row", { name: /Signed by.*Rejected/ })).toBeVisible()
|
||||
// the line nobody decided is still waiting, and is not exported
|
||||
await expect(page.getByRole("row", { name: /Total amount.*To review/ })).toBeVisible()
|
||||
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("button", { name: "Export approved lines (Excel)" }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toBe("report-approved.xlsx")
|
||||
const path = await file.path()
|
||||
const rows = sheetRows(new Uint8Array(readFileSync(path)))
|
||||
expect(rows).toEqual([
|
||||
["Label", "Value", "Page", "Source quote", "Edited by a reviewer"],
|
||||
["Reference", "QR-2026-04", 1, "Reference: QR-2026-04", "no"],
|
||||
["Issue date", "2026-04-02", 1, "Issued on: 2026-04-01", "yes"],
|
||||
])
|
||||
expect(await violations(page)).toEqual([])
|
||||
})
|
||||
|
||||
test("a 30 MB PDF is uploaded in the browser with a progress bar, stored whole, and digested by the platform", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signIn(page, `${unique("uploader")}@example.test`)
|
||||
const { org, workspace, project } = await makeOrganization(page)
|
||||
const base = `/${org}/${workspace}/${project}/documents`
|
||||
await page.goto(base)
|
||||
|
||||
// 30 MB that start like a PDF and are not all one byte, so a piece that arrives short or twice
|
||||
// cannot hash to the same digest. The proxy would cut such a body at 10 MB if it ran for it.
|
||||
const size = 30 * 1024 * 1024
|
||||
const buffer = Buffer.concat([Buffer.from("%PDF-1.4\n"), randomBytes(size - 9)])
|
||||
expect(buffer.length).toBe(size)
|
||||
|
||||
// Slow the upload to 8 MB/s, so that the bar is on screen for seconds and not for a blink: what
|
||||
// the person sees is the requirement, and a loopback upload would be over before a poll looked.
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Network.enable")
|
||||
await cdp.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: 8 * 1024 * 1024,
|
||||
})
|
||||
await page.getByLabel("Upload a document").setInputFiles({ name: "tender.pdf", mimeType: "application/pdf", buffer })
|
||||
await page.getByRole("button", { name: "Upload", exact: true }).click()
|
||||
|
||||
// the bar names the file and how far it has got, and the page reloads when the upload is done
|
||||
await expect(page.getByRole("progressbar", { name: /Uploading tender\.pdf: \d+%/ })).toBeVisible()
|
||||
await expect(page.getByRole("row", { name: /tender\.pdf/ })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
await cdp.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
})
|
||||
|
||||
const listed = await call<{ files: Array<{ filename: string; size: number; sha256: string }> }>(
|
||||
page,
|
||||
"files.file.list",
|
||||
{
|
||||
org,
|
||||
workspace,
|
||||
project,
|
||||
},
|
||||
)
|
||||
const stored = listed.files.find((file) => file.filename === "tender.pdf")
|
||||
expect(stored?.size).toBe(size)
|
||||
expect(stored?.sha256).toBe(createHash("sha256").update(buffer).digest("hex"))
|
||||
})
|
||||
|
||||
test("another organization's member does not see the document", async ({ page, browser }) => {
|
||||
await signIn(page, `${unique("owner")}@example.test`)
|
||||
const mine = await makeOrganization(page)
|
||||
const stranger = await browser.newPage()
|
||||
await signIn(stranger, `${unique("stranger")}@example.test`)
|
||||
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/documents`)
|
||||
// the organization layout answers a non-member as it answers a stranger: the not-found page, as a
|
||||
// 404, with no document list and no names
|
||||
expect(response?.status()).toBe(404)
|
||||
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
|
||||
expect(await stranger.content()).not.toContain(`${mine.org} Review`)
|
||||
await stranger.close()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:guides -- the guides module's live suite; `kit template:prune --without guides` removes this folder.
|
||||
//
|
||||
// The code survey end to end against a running platform (survey.e2e.ts): this app deployed on ConusAI
|
||||
// Cloud with the guides module, and the console. Not part of `pnpm e2e` (the fake platform has no
|
||||
// Guides service); it skips, saying what is missing, when the platform's variables are not set.
|
||||
// pnpm exec playwright test -c e2e/guides/playwright.config.ts
|
||||
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.e2e.ts",
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
...(process.env.E2E_GUIDES_APP_URL ? { baseURL: process.env.E2E_GUIDES_APP_URL } : {}),
|
||||
browserName: "chromium",
|
||||
headless: true,
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:guides -- the guides module's live suite; `kit template:prune --without guides` removes this folder.
|
||||
//
|
||||
// The notes module's code survey (`notes.feedback`, ADR-046) end to end on a running platform:
|
||||
//
|
||||
// 1. the code-guides bundle the image ships (`conusai.guides.code.v1`, ADR-045) carries the survey;
|
||||
// with the deploy job's publish credential it is published here as the deploy step does,
|
||||
// otherwise the deployment's own bundle publication is relied on;
|
||||
// 2. a signed-in member answers it from the notes page ("Give feedback"), with the keyboard where
|
||||
// it matters, attaches a small PNG to the file question (through the app's own server), and
|
||||
// sees the thank-you;
|
||||
// 3. the console's results page counts the response and lists the file by its name, and its CSV
|
||||
// export holds the answers;
|
||||
// 4. the member deletes their account -- the guides module's account-deletion hook calls
|
||||
// `eraseEndUser` -- and the answers and the file are gone from the results and the export.
|
||||
//
|
||||
// What it needs (it skips, naming what is missing, without them):
|
||||
// E2E_GUIDES_APP_URL the deployed app, with the dev sign-in adapter (`/auth/dev-login`) of a
|
||||
// stage environment, so every run signs in a new person
|
||||
// E2E_GUIDES_CONSOLE_URL the console origin
|
||||
// E2E_GUIDES_CONSOLE_STATE a Playwright storage-state file of a console member holding guides:read
|
||||
// on the app's project
|
||||
// E2E_GUIDES_PROJECT the project's slug in the console
|
||||
// and, to publish the bundle from here: CONUSAI_API_URL, CONUSAI_GUIDES_PUBLISH_TOKEN and
|
||||
// CONUSAI_GUIDES_DEPLOYMENT, as the platform hands them to a deploy job. The project's guide settings
|
||||
// must allow the `survey` kind.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { appModules } from "@app/modules"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { guidesBundleOf, publishGuidesAtDeploy } from "@conusai-kit/guides/deploy"
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test"
|
||||
import { call, signIn, unique } from "../support/app"
|
||||
|
||||
const KEY = "notes.feedback"
|
||||
const TITLE = "How are notes working for you?"
|
||||
const env = {
|
||||
app: process.env.E2E_GUIDES_APP_URL,
|
||||
console: process.env.E2E_GUIDES_CONSOLE_URL,
|
||||
consoleState: process.env.E2E_GUIDES_CONSOLE_STATE,
|
||||
project: process.env.E2E_GUIDES_PROJECT,
|
||||
}
|
||||
const missing = Object.entries({
|
||||
E2E_GUIDES_APP_URL: env.app,
|
||||
E2E_GUIDES_CONSOLE_URL: env.console,
|
||||
E2E_GUIDES_CONSOLE_STATE: env.consoleState,
|
||||
E2E_GUIDES_PROJECT: env.project,
|
||||
})
|
||||
.filter(([, value]) => !value)
|
||||
.map(([name]) => name)
|
||||
|
||||
/** A 1 x 1 PNG: the smallest image the platform's content check accepts as one. */
|
||||
const PNG = Buffer.from(
|
||||
"89504e470d0a1a0a0000000d4948445200000001000000010806000000" +
|
||||
"1f15c4890000000d49444154789c6360000002000154a24f5d0000000049454e44ae426082",
|
||||
"hex",
|
||||
)
|
||||
|
||||
/** Letters only, so the platform's masking of free text leaves the marker as it is. */
|
||||
const marker = (): string =>
|
||||
Array.from({ length: 10 }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("")
|
||||
|
||||
test("the image's code-guides bundle carries the notes survey in the unchanged format", () => {
|
||||
const bundle = guidesBundleOf(buildRegistry([...appModules]))
|
||||
expect(bundle.format).toBe("conusai.guides.code.v1")
|
||||
const survey = bundle.guides.find((guide) => guide.key === KEY)
|
||||
expect(survey?.content).toMatchObject({ kind: "survey", trigger: { manual: true }, frequency: "once" })
|
||||
expect(Object.keys(survey ?? {})).toEqual(["key", "content"])
|
||||
})
|
||||
|
||||
test.describe("the notes survey on a running platform", () => {
|
||||
test.skip(missing.length > 0, `needs a running platform: set ${missing.join(", ")}`)
|
||||
|
||||
/** The console page of the survey's results, with its guide id from the guides list. */
|
||||
async function openResults(browser: Browser): Promise<{ page: Page; url: string }> {
|
||||
const context = await browser.newContext({ storageState: env.consoleState ?? "", acceptDownloads: true })
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${env.console}/projects/${env.project}/guides`)
|
||||
await page
|
||||
.getByRole("link", { name: new RegExp(`${KEY.replace(".", "\\.")}|${TITLE.replace("?", "\\?")}`) })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page).toHaveURL(/\/guides\/[^/]+$/)
|
||||
const url = `${page.url().replace(/\/$/, "")}/results`
|
||||
await page.goto(url)
|
||||
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
|
||||
return { page, url }
|
||||
}
|
||||
|
||||
async function respondents(page: Page, url: string): Promise<number> {
|
||||
await page.goto(url)
|
||||
const figure = page.getByRole("figure", { name: "Respondents" })
|
||||
await expect(figure).toBeVisible()
|
||||
const value = /(\d[\d,.\s]*)/.exec((await figure.innerText()).replace("Respondents", ""))?.[1] ?? "NaN"
|
||||
return Number(value.replace(/[,.\s]/g, ""))
|
||||
}
|
||||
|
||||
async function exportCsv(page: Page, url: string): Promise<string> {
|
||||
await page.goto(url)
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("link", { name: "Export CSV" }).click()
|
||||
return readFileSync((await (await download).path()) ?? "", "utf8")
|
||||
}
|
||||
|
||||
test("a member answers it, the console counts and exports the response, and erasure removes it", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
// 1. Publication: the deploy step, when this run holds the deploy job's credential.
|
||||
if (process.env.CONUSAI_GUIDES_PUBLISH_TOKEN && process.env.CONUSAI_GUIDES_DEPLOYMENT) {
|
||||
const outcome = await publishGuidesAtDeploy({ registry: buildRegistry([...appModules]), env: process.env })
|
||||
expect(outcome.status, JSON.stringify(outcome)).toBe("published")
|
||||
if (outcome.status === "published") {
|
||||
expect(outcome.guides.find((guide) => guide.key === KEY)?.status).toBe("published")
|
||||
}
|
||||
} else {
|
||||
test.info().annotations.push({
|
||||
type: "publication",
|
||||
description: "no publish credential: relying on the deployment's guides.bundle publication (ADR-045)",
|
||||
})
|
||||
}
|
||||
|
||||
const results = await openResults(browser)
|
||||
const before = await respondents(results.page, results.url)
|
||||
|
||||
// 2. A new member answers the survey from the notes page.
|
||||
const email = `${unique("surveyed")}@example.test`
|
||||
await signIn(page, email)
|
||||
const org = unique("survey")
|
||||
await call(page, "tenancy.org.create", { slug: org, name: `Org ${org}` })
|
||||
const workspace = (
|
||||
await call<{ slug: string }>(page, "tenancy.workspace.create", {
|
||||
org,
|
||||
slug: "main",
|
||||
name: "Main",
|
||||
visibility: "org",
|
||||
})
|
||||
).slug
|
||||
const project = (
|
||||
await call<{ slug: string }>(page, "tenancy.project.create", {
|
||||
org,
|
||||
workspace,
|
||||
slug: "notes",
|
||||
name: "Notes",
|
||||
moduleKey: "notes",
|
||||
})
|
||||
).slug
|
||||
await page.goto(`/${org}/${workspace}/${project}/notes`)
|
||||
await page.getByRole("button", { name: "Give feedback" }).click()
|
||||
const dialog = page.getByRole("dialog", { name: TITLE })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText("Question 1 of 4")).toBeVisible()
|
||||
await dialog.getByRole("radio", { name: "4" }).check()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialog.getByText("Question 2 of 4")).toBeVisible()
|
||||
await dialog.getByRole("radio", { name: "Meeting notes" }).check()
|
||||
await dialog.getByRole("button", { name: "Next" }).click()
|
||||
const text = `Faster search ${marker()}`
|
||||
const field = dialog.getByRole("textbox", { name: "What would make notes better for you?" })
|
||||
await expect(field).toHaveAccessibleDescription(/Do not enter personal data/)
|
||||
await field.fill(text)
|
||||
// The answer is never kept in the browser's storage.
|
||||
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(text)
|
||||
await dialog.getByRole("button", { name: "Next" }).click()
|
||||
|
||||
// The file question: a small PNG, uploaded through the app's own server as it is chosen.
|
||||
await expect(dialog.getByText("Question 4 of 4")).toBeVisible()
|
||||
const fileName = `screenshot-${marker()}.png`
|
||||
const input = dialog.getByLabel("Choose files")
|
||||
await expect(input).toHaveAccessibleDescription(/Files must not contain personal data such as identity documents\./)
|
||||
await input.setInputFiles({ name: fileName, mimeType: "image/png", buffer: PNG })
|
||||
await expect(dialog.getByRole("status")).toHaveText(`${fileName} attached.`)
|
||||
await expect(dialog.getByRole("list", { name: "Attached files" })).toContainText(fileName)
|
||||
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(fileName)
|
||||
await dialog.getByRole("button", { name: "Submit" }).click()
|
||||
await expect(dialog.getByRole("status")).toHaveText("Thank you. We read every answer.")
|
||||
await dialog.getByRole("button", { name: "Close" }).click()
|
||||
await expect(page.getByRole("dialog", { name: TITLE })).toHaveCount(0)
|
||||
|
||||
// 3. The console counts the response, lists the file by its name, and exports the answers.
|
||||
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before + 1)
|
||||
await results.page.goto(results.url)
|
||||
await expect(results.page.getByRole("link", { name: fileName })).toBeVisible()
|
||||
const csv = await exportCsv(results.page, results.url)
|
||||
const header = csv.split(/\r?\n/)[0]
|
||||
expect(header).toBe("environment,version,respondent,question_id,question,answer_ids,answer_labels,text,answered_at")
|
||||
const mine = csv.split(/\r?\n/).find((line) => line.includes(text))
|
||||
expect(mine, "the free-text answer is in the export").toBeTruthy()
|
||||
const respondent = mine?.split(",")[2] ?? ""
|
||||
const rows = csv.split(/\r?\n/).filter((line) => line.split(",")[2] === respondent)
|
||||
// The file answer's row, if the export has one, is the console's to word; the others are pinned.
|
||||
expect(
|
||||
rows
|
||||
.map((line) => line.split(",")[3])
|
||||
.filter((id) => id !== "screenshot")
|
||||
.sort(),
|
||||
).toEqual(["ease", "improve", "use"])
|
||||
expect(rows.find((line) => line.split(",")[3] === "ease")).toContain(",4,")
|
||||
expect(rows.find((line) => line.split(",")[3] === "use")).toContain("meetings")
|
||||
|
||||
// 4. The member deletes the account: the guides module erases the end user on the platform.
|
||||
await page.goto("/settings/account")
|
||||
await page.getByLabel("Type your e-mail address to confirm").fill(email)
|
||||
await page.getByRole("button", { name: "Delete my account" }).click()
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
|
||||
).toBeVisible()
|
||||
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before)
|
||||
await results.page.goto(results.url)
|
||||
await expect(results.page.getByRole("link", { name: fileName })).toHaveCount(0)
|
||||
const after = await exportCsv(results.page, results.url)
|
||||
expect(after).not.toContain(text)
|
||||
expect(after).not.toContain(fileName)
|
||||
expect(after.split(/\r?\n/).some((line) => line.split(",")[2] === respondent)).toBe(false)
|
||||
await results.page.context().close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The example module in a browser (plan item AT2.2): add a note, attach a file through the files
|
||||
// module's Blob upload, change and remove things, and delete the note -- with axe on every screen it
|
||||
// shows. Another organization's member sees none of it.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { call, callRaw, makeOrganization, platformState, signIn, unique, violations } from "./support/app"
|
||||
|
||||
const MINUTES = Buffer.from("Agreed on Friday: ship the pilot.\n")
|
||||
|
||||
async function uploadText(page: Page, scope: Awaited<ReturnType<typeof makeOrganization>>, name: string) {
|
||||
const begun = await call<{ fileId: string; parts: Array<{ number: number }> }>(page, "files.file.beginUpload", {
|
||||
...scope,
|
||||
filename: name,
|
||||
contentType: "text/plain",
|
||||
size: 1,
|
||||
})
|
||||
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
|
||||
const part = begun.parts[0]
|
||||
if (!part) throw new Error("the one-byte upload did not return a part")
|
||||
const sent = await page.request.put(
|
||||
`/api/v1/files.file.putPart?${new URLSearchParams({ ...scope, fileId: begun.fileId, part: String(part.number) })}`,
|
||||
{
|
||||
data: Buffer.from("x"),
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"x-csrf-token": csrf ?? "",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(sent.ok()).toBe(true)
|
||||
const receipt = (await sent.json()) as { etag: string }
|
||||
await call(page, "files.file.completeUpload", {
|
||||
...scope,
|
||||
fileId: begun.fileId,
|
||||
parts: [{ number: part.number, etag: receipt.etag }],
|
||||
})
|
||||
return begun.fileId
|
||||
}
|
||||
|
||||
test("a note takes attachments, is edited and removed, and every screen passes axe", async ({ page }) => {
|
||||
await signIn(page, `${unique("author")}@example.test`)
|
||||
const { org, workspace, project } = await makeOrganization(page)
|
||||
const base = `/${org}/${workspace}/${project}/notes`
|
||||
|
||||
// the organization's home links to the module from the manifest's navigation entry
|
||||
await page.goto(`/${org}`)
|
||||
await expect(page.getByRole("link", { name: "Notes", exact: true })).toBeVisible()
|
||||
|
||||
await page.goto(base)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Notes" })).toBeVisible()
|
||||
await expect(page.getByText("No notes yet.")).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
await page.getByLabel("Title").fill("Kick-off")
|
||||
await page.getByLabel("Text").fill("Agenda and owners")
|
||||
await page.getByRole("button", { name: "Add note" }).click()
|
||||
|
||||
// the note's own page
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Kick-off" })).toBeVisible()
|
||||
await expect(page.getByText("Nothing is attached.")).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// an upload through the files module (the platform's Blob store), then the link
|
||||
await page.getByLabel("Attach a file").setInputFiles({ name: "minutes.txt", mimeType: "text/plain", buffer: MINUTES })
|
||||
await page.getByRole("button", { name: "Attach", exact: true }).click()
|
||||
await expect(page.getByRole("row", { name: /minutes\.txt.*text\/plain.*1 KB/ })).toBeVisible()
|
||||
await expect(page.getByText("1 attachment", { exact: true })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("link", { name: "Download minutes.txt" }).click()
|
||||
const downloaded = await download
|
||||
expect(downloaded.suggestedFilename()).toBe("minutes.txt")
|
||||
const downloadedPath = await downloaded.path()
|
||||
expect(readFileSync(downloadedPath)).toEqual(MINUTES)
|
||||
|
||||
// what the worker sent the platform: ONE audit event and ONE usage fact for the upload, one audit event
|
||||
// and one usage fact for the note, one audit event for the link
|
||||
const noteId = page.url().split("/").pop() ?? ""
|
||||
const { files } = await call<{ files: Array<{ fileId: string }> }>(page, "files.file.list", {
|
||||
org,
|
||||
workspace,
|
||||
project,
|
||||
})
|
||||
expect(files).toHaveLength(1)
|
||||
const fileId = files[0]?.fileId ?? ""
|
||||
await expect(async () => {
|
||||
const state = await platformState()
|
||||
// The target carries the organization as `<org id>/<resource id>` (src/server/audit.ts), so a
|
||||
// resource matches by its trailing segment.
|
||||
const audited = (id: string) =>
|
||||
state.audit.filter((event) => event.resource_id?.endsWith(`/${id}`)).map((event) => event.action)
|
||||
expect(audited(fileId)).toEqual(["files.file_uploaded"])
|
||||
expect(audited(noteId).sort()).toEqual(["notes.attachment_added", "notes.note_created"])
|
||||
const metered = (key: string) => state.usage.filter((fact) => fact.idempotency_key === key)
|
||||
expect(metered(`files_bytes_stored.${fileId}`)).toEqual([
|
||||
expect.objectContaining({ meter: "files_bytes_stored", quantity: String(MINUTES.length) }),
|
||||
])
|
||||
expect(metered(`notes_created.${noteId}`)).toEqual([
|
||||
expect.objectContaining({ meter: "notes_created", quantity: "1" }),
|
||||
])
|
||||
// and the bytes are in the platform's Blob store, once, under the organization's own prefix
|
||||
expect(
|
||||
state.blobs.filter((blob) => blob.size === MINUTES.length && blob.pathname.includes(`/files/`)),
|
||||
).toHaveLength(1)
|
||||
}).toPass({ timeout: 30_000 })
|
||||
|
||||
// edit: the form carries the version it was read at
|
||||
await page.getByLabel("Title").fill("Kick-off call")
|
||||
await page.getByRole("button", { name: "Save changes" }).click()
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Kick-off call" })).toBeVisible()
|
||||
|
||||
// the list shows the note with its attachment count
|
||||
await page.goto(base)
|
||||
await expect(page.getByRole("row", { name: /Kick-off call.*1/ })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// remove the attachment, then delete the note after being asked
|
||||
await page.getByRole("link", { name: "Open Kick-off call" }).click()
|
||||
await page.getByRole("button", { name: "Remove minutes.txt" }).click()
|
||||
await expect(page.getByText("Nothing is attached.")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Delete note" }).click()
|
||||
expect(await violations(page)).toEqual([])
|
||||
await page.getByRole("button", { name: "Yes, delete the note" }).click()
|
||||
await expect(page.getByText("No notes yet.")).toBeVisible()
|
||||
})
|
||||
|
||||
test("a second editor's stale save is refused and says so", async ({ page }) => {
|
||||
await signIn(page, `${unique("author")}@example.test`)
|
||||
const { org, workspace, project } = await makeOrganization(page)
|
||||
const base = `/${org}/${workspace}/${project}/notes`
|
||||
await page.goto(base)
|
||||
await page.getByLabel("Title").fill("Plan")
|
||||
await page.getByRole("button", { name: "Add note" }).click()
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Plan" })).toBeVisible()
|
||||
|
||||
// the same note opened in a second tab, saved there first
|
||||
const second = await page.context().newPage()
|
||||
await second.goto(page.url())
|
||||
await second.getByLabel("Title").fill("Plan v2")
|
||||
await second.getByRole("button", { name: "Save changes" }).click()
|
||||
await expect(second.getByRole("heading", { level: 1, name: "Plan v2" })).toBeVisible()
|
||||
|
||||
// the first tab still holds version 1
|
||||
await page.getByLabel("Title").fill("Plan v3")
|
||||
await page.getByRole("button", { name: "Save changes" }).click()
|
||||
await expect(page.getByRole("alert")).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
await second.close()
|
||||
})
|
||||
|
||||
test("concurrent attachments cannot exceed the per-note cap", async ({ page }) => {
|
||||
await signIn(page, `${unique("quota-owner")}@example.test`)
|
||||
const scope = await makeOrganization(page)
|
||||
const note = await call<{ noteId: string }>(page, "notes.note.create", {
|
||||
...scope,
|
||||
title: "Attachment quota",
|
||||
body: "",
|
||||
})
|
||||
const existingFiles: string[] = []
|
||||
for (let index = 0; index < 19; index += 1) {
|
||||
const fileId = await uploadText(page, scope, `quota-${index}.txt`)
|
||||
existingFiles.push(fileId)
|
||||
await call(page, "notes.attachment.add", { ...scope, noteId: note.noteId, fileId })
|
||||
}
|
||||
|
||||
const contenders = await Promise.all([
|
||||
uploadText(page, scope, "quota-race-a.txt"),
|
||||
uploadText(page, scope, "quota-race-b.txt"),
|
||||
])
|
||||
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
|
||||
const results = await Promise.all(
|
||||
contenders.map((fileId) =>
|
||||
callRaw(page.request, csrf ?? "", "notes.attachment.add", { ...scope, noteId: note.noteId, fileId }),
|
||||
),
|
||||
)
|
||||
expect(results.map((result) => result.status()).sort()).toEqual([200, 409])
|
||||
const detail = await call<{ attachments: unknown[] }>(page, "notes.note.get", { ...scope, noteId: note.noteId })
|
||||
expect(detail.attachments).toHaveLength(20)
|
||||
expect(existingFiles).toHaveLength(19)
|
||||
})
|
||||
|
||||
test("another organization's member does not see the notes", async ({ page, browser }) => {
|
||||
await signIn(page, `${unique("owner")}@example.test`)
|
||||
const mine = await makeOrganization(page)
|
||||
const stranger = await browser.newPage()
|
||||
await signIn(stranger, `${unique("stranger")}@example.test`)
|
||||
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/notes`)
|
||||
expect(response?.status()).toBe(404)
|
||||
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
|
||||
await stranger.close()
|
||||
})
|
||||
|
||||
test("a path the module does not know is the not-found page", async ({ page }) => {
|
||||
await signIn(page, `${unique("author")}@example.test`)
|
||||
const { org, workspace, project } = await makeOrganization(page)
|
||||
const unknownModule = await page.goto(`/${org}/${workspace}/${project}/nothing-here`)
|
||||
expect(unknownModule?.status()).toBe(404)
|
||||
const unknownNote = await page.goto(`/${org}/${workspace}/${project}/notes/00000000-0000-0000-0000-000000000000`)
|
||||
expect(unknownNote?.status()).toBe(404)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The end-to-end suite (`pnpm e2e`): Chromium against the whole app on this machine -- web, worker and
|
||||
// migrate job over a real Postgres with the three roles, and the fake platform for Blob and OCR. One
|
||||
// worker, in order: the specs share the stack, and each makes its own organization.
|
||||
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.E2E_WEB_PORT ?? 3100)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.spec.ts",
|
||||
testIgnore: ["better-auth/**"], // kit:convex-better-auth -- gate J-auth has its own run (better-auth/playwright.config.ts)
|
||||
globalSetup: "./support/global-setup.ts",
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 20_000 },
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
browserName: "chromium",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,472 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The shell in a browser (plan item AT4.4): sign-in and its error states, the invitation link in every
|
||||
// state it can be in, the organization switcher, the workspace and project pages, the 403 and 404
|
||||
// pages (and that a stranger cannot tell a missing organization from one they are not in), the
|
||||
// language and theme controls, and the demo data `pnpm dev` writes. Each screen passes axe, in
|
||||
// English, and the ones that carry words of their own also in Lithuanian.
|
||||
|
||||
import { type Browser, type BrowserContext, expect, type Page, test } from "@playwright/test"
|
||||
import { call, makeOrganization, platformState, signIn, unique, violations } from "./support/app"
|
||||
|
||||
const base = `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`
|
||||
|
||||
/** A browser of its own, so two people can be signed in at once. */
|
||||
async function person(browser: Browser, email: string): Promise<{ page: Page; context: BrowserContext }> {
|
||||
const context = await browser.newContext({ baseURL: base })
|
||||
const page = await context.newPage()
|
||||
await signIn(page, email)
|
||||
return { page, context }
|
||||
}
|
||||
|
||||
/** The alerts of the page's own content: Next.js keeps a `role="alert"` route announcer of its own in the document. */
|
||||
const alerts = (page: Page) => page.locator("main [role=alert], p.app-error[role=alert]")
|
||||
|
||||
const mainText = (page: Page): Promise<string> => page.locator("main").innerText()
|
||||
|
||||
test("the sign-in page offers the way in, words each failure in the person's language, and passes axe", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/sign-in")
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Sign in" })).toBeVisible()
|
||||
await expect(alerts(page)).toHaveCount(0)
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
for (const [code, words] of [
|
||||
["denied", /The identity provider did not let you in/],
|
||||
["noTransaction", /was not started in this browser/],
|
||||
["sessionExpired", /Your session has ended/],
|
||||
["provider", /could not complete the sign-in/],
|
||||
["failed", /Signing in did not work/],
|
||||
] as const) {
|
||||
await page.goto(`/auth/sign-in?error=${code}`)
|
||||
await expect(alerts(page)).toContainText(words)
|
||||
}
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// Only the five words are ever shown: a code from a URL is not text to print.
|
||||
await page.goto("/auth/sign-in?error=%3Cscript%3Ealert(1)%3C/script%3E")
|
||||
await expect(alerts(page)).toHaveCount(0)
|
||||
|
||||
// In Lithuanian, with the same structure.
|
||||
await page.goto("/auth/sign-in?error=denied")
|
||||
await page.getByLabel("Language").selectOption("lt")
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Prisijungimas" })).toBeVisible()
|
||||
await expect(alerts(page)).toContainText("Tapatybės teikėjas jūsų neįleido")
|
||||
expect(await violations(page)).toEqual([])
|
||||
})
|
||||
|
||||
test("a failed sign-in ends at the sign-in page with a reason, never at a 500", async ({ page }) => {
|
||||
// The provider says no.
|
||||
const denied = await page.request.get("/auth/callback?error=access_denied&error_description=secret+words", {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(denied.status()).toBe(302)
|
||||
expect(denied.headers().location).toBe("/auth/sign-in?error=denied")
|
||||
// A callback no sign-in in this browser started.
|
||||
const orphan = await page.request.get("/auth/callback?code=abc&state=xyz", { maxRedirects: 0 })
|
||||
expect(orphan.status()).toBe(302)
|
||||
// (this stack has no identity provider, so there is nothing a callback could complete)
|
||||
expect(orphan.headers().location).toBe("/auth/sign-in?error=failed")
|
||||
// An address the dev sign-in will not take.
|
||||
const bad = await page.request.get("/auth/dev-login?email=nobody", { maxRedirects: 0 })
|
||||
expect(bad.status()).toBe(302)
|
||||
expect(bad.headers().location).toBe("/auth/sign-in?error=failed")
|
||||
})
|
||||
|
||||
test("an address that needs a person is sent to sign in and brought back, whoever it names", async ({ page }) => {
|
||||
await page.goto("/acme-review/general")
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in\?return_to=%2Facme-review%2Fgeneral$/)
|
||||
await page.getByLabel("E-mail address").fill("ada@example.test")
|
||||
await page.getByRole("button", { name: "Sign in" }).click()
|
||||
await expect(page).toHaveURL(/\/acme-review\/general$/)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Workspace General" })).toBeVisible()
|
||||
// A return address outside this app is not followed.
|
||||
const away = await page.request.get("/auth/dev-login?email=ada@example.test&return_to=https://evil.example/", {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(away.headers().location).toBe("/")
|
||||
})
|
||||
|
||||
test("the demo data: two organizations, one with a tenant brand, and the switcher between them", async ({ page }) => {
|
||||
await signIn(page, "ada@example.test")
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("link", { name: "Acme Review" })).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: "Northwind Traders" })).toBeVisible()
|
||||
|
||||
await page.goto("/acme-review")
|
||||
await expect(page.locator("[data-tenant-brand]")).toHaveCount(0)
|
||||
await expect(page.getByLabel("Switch organization")).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
await page.getByLabel("Switch organization").selectOption("northwind")
|
||||
await expect(page).toHaveURL(/\/northwind$/)
|
||||
await expect(page.locator('[data-tenant-brand="#1a1a8c"]')).toHaveCount(1)
|
||||
await expect(page.getByRole("link", { name: "Northwind", exact: true })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// Linus is in one organization only: nothing to switch between.
|
||||
const linus = await person(page.context().browser() as Browser, "linus@example.test")
|
||||
await linus.page.goto("/acme-review")
|
||||
await expect(linus.page.getByLabel("Switch organization")).toHaveCount(0)
|
||||
await linus.context.close()
|
||||
})
|
||||
|
||||
test("the browser can initialize MCP, list exactly the opted-in tools, and call the scoped note-list tool", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signIn(page, "ada@example.test")
|
||||
const scope = await makeOrganization(page, "mcp")
|
||||
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
"x-csrf-token": csrf ?? "",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
}
|
||||
const rpcBody = async (response: Awaited<ReturnType<typeof page.request.post>>) => {
|
||||
const text = await response.text()
|
||||
if (response.headers()["content-type"]?.includes("text/event-stream")) {
|
||||
const data = text.split("\n").find((line) => line.startsWith("data: "))
|
||||
if (!data) throw new Error("MCP response had no event data")
|
||||
return JSON.parse(data.slice("data: ".length))
|
||||
}
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
const initialize = await page.request.post("/api/mcp", {
|
||||
headers,
|
||||
data: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "app-template-browser-e2e", version: "1.0.0" },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(initialize.status()).toBe(200)
|
||||
expect((await rpcBody(initialize)).result.protocolVersion).toBe("2025-11-25")
|
||||
|
||||
const listed = await page.request.post("/api/mcp", { headers, data: { jsonrpc: "2.0", id: 2, method: "tools/list" } })
|
||||
const tools = (await rpcBody(listed)).result.tools as Array<{ name: string }>
|
||||
expect(listed.status()).toBe(200)
|
||||
// Exactly the procedures whose contract opts in with `.tool()` (docs/reference/app-kit.md), in registry
|
||||
// order: the notes list, the knowledge module's retrieval and the agent module's run status, which opt
|
||||
// in on purpose; no other read or write of any module (test/mcp.test.ts asserts the same list).
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["agent.run.status", "knowledge.search", "notes.note.list"])
|
||||
|
||||
const called = await page.request.post("/api/mcp", {
|
||||
headers,
|
||||
data: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "notes.note.list", arguments: scope },
|
||||
},
|
||||
})
|
||||
expect(called.status()).toBe(200)
|
||||
expect((await rpcBody(called)).result).toMatchObject({
|
||||
structuredContent: { notes: [], hasMore: false },
|
||||
})
|
||||
|
||||
const refused = await page.request.post("/api/mcp", {
|
||||
headers,
|
||||
data: { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "notes.note.create", arguments: {} } },
|
||||
})
|
||||
expect((await rpcBody(refused)).error.code).toBe(-32602)
|
||||
})
|
||||
|
||||
test("MCP enforces the real session, CSRF token, and tenant scope at its HTTP boundary", async ({ browser }) => {
|
||||
const owner = await person(browser, `${unique("mcp-owner")}@example.test`)
|
||||
const foreign = await makeOrganization(owner.page, "mcp-private")
|
||||
const secret = unique("tenant-private-note")
|
||||
await call(owner.page, "notes.note.create", { ...foreign, title: "Private note", body: secret })
|
||||
|
||||
const noSession = await browser.newContext({ baseURL: base })
|
||||
const anonymous = await noSession.newPage()
|
||||
const requestBody = (id: number, method: string, params?: unknown) => ({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
})
|
||||
const noAuth = await anonymous.request.post("/api/mcp", {
|
||||
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
||||
data: requestBody(1, "initialize"),
|
||||
})
|
||||
expect(noAuth.status()).toBe(401)
|
||||
await noSession.close()
|
||||
|
||||
const csrf = await owner.page.locator('meta[name="csrf-token"]').getAttribute("content")
|
||||
const initialized = await owner.page.request.post("/api/mcp", {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-csrf-token": csrf ?? "",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
},
|
||||
data: requestBody(2, "initialize", {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "app-template-security-e2e", version: "1.0.0" },
|
||||
}),
|
||||
})
|
||||
expect(initialized.status()).toBe(200)
|
||||
|
||||
const withoutCsrf = await owner.page.request.post("/api/mcp", {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
},
|
||||
data: requestBody(3, "tools/list"),
|
||||
})
|
||||
expect(withoutCsrf.status()).toBe(403)
|
||||
|
||||
const stranger = await person(browser, `${unique("mcp-stranger")}@example.test`)
|
||||
const strangerCsrf = await stranger.page.locator('meta[name="csrf-token"]').getAttribute("content")
|
||||
const crossTenant = await stranger.page.request.post("/api/mcp", {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-csrf-token": strangerCsrf ?? "",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
},
|
||||
data: requestBody(4, "tools/call", {
|
||||
name: "notes.note.list",
|
||||
arguments: foreign,
|
||||
}),
|
||||
})
|
||||
expect(crossTenant.status()).toBe(200)
|
||||
const responseText = await crossTenant.text()
|
||||
const event = responseText.split("\n").find((line) => line.startsWith("data: "))
|
||||
const rpcResponse = crossTenant.headers()["content-type"]?.includes("text/event-stream")
|
||||
? event?.slice("data: ".length)
|
||||
: responseText
|
||||
expect(rpcResponse).toBeDefined()
|
||||
const result = JSON.parse(rpcResponse ?? "null") as {
|
||||
result?: { isError?: boolean; content?: Array<{ text?: string }>; structuredContent?: unknown }
|
||||
}
|
||||
expect(result.result?.isError).toBe(true)
|
||||
expect(JSON.stringify(result)).not.toContain(secret)
|
||||
expect(JSON.stringify(result.result?.structuredContent ?? null)).not.toContain(secret)
|
||||
expect(result.result?.content?.[0]?.text).toBe("The tool request was refused.")
|
||||
|
||||
await owner.context.close()
|
||||
await stranger.context.close()
|
||||
})
|
||||
|
||||
test("a stranger cannot tell an organization that is not theirs from one that does not exist", async ({ browser }) => {
|
||||
const { page, context } = await person(browser, `${unique("stranger")}@example.test`)
|
||||
const seen: Array<{ status: number; text: string }> = []
|
||||
for (const path of ["/acme-review", "/no-such-organization", "/acme-review/general/demo", "/northwind/general"]) {
|
||||
const response = await page.goto(path)
|
||||
seen.push({ status: response?.status() ?? 0, text: await mainText(page) })
|
||||
}
|
||||
expect(seen.map((entry) => entry.status)).toEqual([404, 404, 404, 404])
|
||||
expect(new Set(seen.map((entry) => entry.text)).size).toBe(1)
|
||||
expect(seen[0]?.text).toContain("Page not found")
|
||||
expect(await violations(page)).toEqual([])
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test("a member whose role does not allow a page gets a 403 and no link to it", async ({ browser }) => {
|
||||
const { page, context } = await person(browser, "linus@example.test")
|
||||
for (const path of ["/acme-review/settings", "/acme-review/audit"]) {
|
||||
const response = await page.goto(path)
|
||||
expect(response?.status(), path).toBe(403)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "You do not have access" })).toBeVisible()
|
||||
}
|
||||
expect(await violations(page)).toEqual([])
|
||||
await page.goto("/acme-review")
|
||||
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0)
|
||||
// The members page shows a viewer the list and none of the controls.
|
||||
await page.goto("/acme-review/members")
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Members" })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: "Invite" })).toHaveCount(0)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test("an organization, a workspace and a project are made from the pages, and each page passes axe", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, context } = await person(browser, `${unique("founder")}@example.test`)
|
||||
const slug = unique("studio")
|
||||
|
||||
await page.goto("/onboarding")
|
||||
await page.getByLabel("Name", { exact: true }).fill("Studio")
|
||||
await page.getByLabel("Short name").fill(slug)
|
||||
await page.getByRole("button", { name: "Create" }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}$`))
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
await page.goto(`/${slug}/workspaces`)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Workspaces" })).toBeVisible()
|
||||
await page.getByLabel("Name", { exact: true }).fill("Research")
|
||||
await page.getByLabel("Short name").fill("research")
|
||||
await page.getByRole("button", { name: "Create" }).click()
|
||||
await expect(page.getByRole("link", { name: "Research" })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// A workspace cannot take the name of a page of the shell.
|
||||
await page.getByLabel("Name", { exact: true }).fill("Members")
|
||||
await page.getByLabel("Short name").fill("members")
|
||||
await page.getByRole("button", { name: "Create" }).click()
|
||||
await expect(alerts(page)).toContainText("reserved")
|
||||
|
||||
await page.goto(`/${slug}/research`)
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Workspace Research" })).toBeVisible()
|
||||
await expect(page.getByText("There are no projects in this workspace yet.")).toBeVisible()
|
||||
await page.getByLabel("Name", { exact: true }).fill("Field notes")
|
||||
await page.getByLabel("Short name").fill("field-notes")
|
||||
await page.getByLabel("Module").selectOption("notes")
|
||||
await page.getByRole("button", { name: "Create" }).click()
|
||||
await page.getByRole("link", { name: "Field notes" }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/research/field-notes$`))
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Project Field notes" })).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: "Notes" })).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: "Documents" })).toBeVisible()
|
||||
await expect(page.getByRole("navigation", { name: "Path" })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test("an invitation: made by an administrator, opened signed out, accepted once, and every other state worded", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const owner = await person(browser, `${unique("owner")}@example.test`)
|
||||
const slug = unique("guild")
|
||||
await call(owner.page, "tenancy.org.create", { slug, name: "Guild" })
|
||||
|
||||
await owner.page.goto(`/${slug}/members`)
|
||||
await owner.page.getByLabel("E-mail address").fill("invited@example.test")
|
||||
await owner.page.getByLabel("Role").first().selectOption("member")
|
||||
await owner.page.getByRole("button", { name: "Invite" }).click()
|
||||
await expect(owner.page.getByText("It works once, for that address only")).toBeVisible()
|
||||
const link = await owner.page.getByLabel("Invitation link").inputValue()
|
||||
expect(link).toMatch(new RegExp(`/invitations/${slug}/[A-Za-z0-9_-]{20,}$`))
|
||||
expect(await violations(owner.page)).toEqual([])
|
||||
await owner.page.reload()
|
||||
await expect(owner.page.getByRole("cell", { name: "invited@example.test" })).toBeVisible()
|
||||
const path = new URL(link).pathname
|
||||
|
||||
// Signed out: asked to sign in, brought back to the same link, and opening it consumed nothing.
|
||||
const guestContext = await browser.newContext({ baseURL: base })
|
||||
const guest = await guestContext.newPage()
|
||||
await guest.goto(path)
|
||||
await expect(guest.getByRole("heading", { level: 1, name: "Your invitation" })).toBeVisible()
|
||||
await expect(guest.getByText("Sign in with the e-mail address the invitation was sent to")).toBeVisible()
|
||||
expect(await violations(guest)).toEqual([])
|
||||
await guest.getByRole("link", { name: "Sign in" }).last().click()
|
||||
await expect(guest).toHaveURL(/\/auth\/sign-in\?return_to=/)
|
||||
await guest.getByLabel("E-mail address").fill("invited@example.test")
|
||||
await guest.getByRole("button", { name: "Sign in" }).click()
|
||||
await expect(guest).toHaveURL(new RegExp(`${path}$`))
|
||||
|
||||
// Signed in as somebody else: the one "not valid", which does not say whether the token exists.
|
||||
const other = await person(browser, `${unique("other")}@example.test`)
|
||||
await other.page.goto(path)
|
||||
await other.page.getByRole("button", { name: "Accept the invitation" }).click()
|
||||
await expect(alerts(other.page)).toContainText("not valid for the account you are signed in with")
|
||||
await other.context.close()
|
||||
|
||||
// The invitee: accepted, once.
|
||||
await expect(guest.getByText("You are signed in as invited@example.test.")).toBeVisible()
|
||||
expect(await violations(guest)).toEqual([])
|
||||
await guest.getByRole("button", { name: "Accept the invitation" }).click()
|
||||
await expect(guest.getByRole("status")).toContainText(`You joined ${slug}.`)
|
||||
await guest.getByRole("link", { name: `Open ${slug}` }).click()
|
||||
await expect(guest).toHaveURL(new RegExp(`/${slug}$`))
|
||||
|
||||
// The same link again: used. In Lithuanian, too.
|
||||
await guest.goto(path)
|
||||
await guest.getByRole("button", { name: "Accept the invitation" }).click()
|
||||
await expect(alerts(guest)).toContainText("This invitation has already been used.")
|
||||
await guest.getByLabel("Language").selectOption("lt")
|
||||
await guest.getByRole("button", { name: "Priimti kvietimą" }).click()
|
||||
await expect(alerts(guest)).toContainText("Šis kvietimas jau panaudotas.")
|
||||
expect(await violations(guest)).toEqual([])
|
||||
|
||||
await guestContext.close()
|
||||
await owner.context.close()
|
||||
})
|
||||
|
||||
test("the language is the person's own and follows them; the theme is this screen's", async ({ browser }) => {
|
||||
const email = `${unique("polyglot")}@example.test`
|
||||
const first = await person(browser, email)
|
||||
await first.page.goto("/")
|
||||
await expect(first.page.getByRole("heading", { level: 1, name: "Organizations" })).toBeVisible()
|
||||
await first.page.getByLabel("Language").selectOption("lt")
|
||||
await expect(first.page.getByRole("heading", { level: 1, name: "Organizacijos" })).toBeVisible()
|
||||
await expect(first.page.locator("html")).toHaveAttribute("lang", "lt")
|
||||
expect(await violations(first.page)).toEqual([])
|
||||
await first.context.close()
|
||||
|
||||
// Another browser, no cookie: the choice was saved on the user.
|
||||
const second = await person(browser, email)
|
||||
await second.page.goto("/")
|
||||
await expect(second.page.getByRole("heading", { level: 1, name: "Organizacijos" })).toBeVisible()
|
||||
|
||||
// The theme: dark first, the root carries the class, and the server renders it back.
|
||||
const root = second.page.locator("html")
|
||||
await expect(root).not.toHaveClass(/light/)
|
||||
await second.page.getByRole("button", { name: /^Tema: Tamsi$/ }).click()
|
||||
await expect(root).toHaveClass(/light/)
|
||||
await expect(second.page.locator(".light")).toHaveCount(1)
|
||||
await second.page.reload()
|
||||
await expect(root).toHaveClass(/light/)
|
||||
expect(await violations(second.page)).toEqual([])
|
||||
const html = await (await second.page.request.get("/")).text()
|
||||
expect(html).toMatch(/<html[^>]*class="light"/)
|
||||
await second.context.close()
|
||||
})
|
||||
|
||||
test("the health and readiness endpoints answer booleans and nothing that names a reason", async ({ request }) => {
|
||||
const health = await request.get("/api/health")
|
||||
expect(health.status()).toBe(200)
|
||||
expect(await health.json()).toMatchObject({ status: "ok" })
|
||||
|
||||
const ready = await request.get("/api/ready")
|
||||
expect(ready.status()).toBe(200)
|
||||
expect(await ready.json()).toEqual({
|
||||
ready: true,
|
||||
checks: { store: true, reader: true, migrations: true, platform: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("a request carries its trace id into the web shell's logs and spans, and no secret leaves", async ({ page }) => {
|
||||
await signIn(page, "ada@example.test")
|
||||
const trace = "4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
const csrf = (await page.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
|
||||
const response = await page.request.post("/api/v1/tenancy.org.get", {
|
||||
headers: {
|
||||
"x-csrf-token": csrf,
|
||||
"content-type": "application/json",
|
||||
"sec-fetch-site": "same-origin",
|
||||
traceparent: `00-${trace}-00f067aa0ba902b7-01`,
|
||||
"x-request-id": "e2e-request-1",
|
||||
},
|
||||
data: { org: "acme-review" },
|
||||
})
|
||||
expect(response.status()).toBe(200)
|
||||
expect(response.headers()["x-request-id"]).toBe("e2e-request-1")
|
||||
|
||||
await expect(async () => {
|
||||
const { otel } = await platformState()
|
||||
// The bodies of what the platform received: the request's own headers (the token it authenticates with) are not what is under test.
|
||||
const logs = JSON.stringify(otel.logs.map((record) => record.body))
|
||||
expect(logs).toContain(trace)
|
||||
expect(logs).toContain("tenancy.org.get")
|
||||
const traces = JSON.stringify(otel.traces.map((record) => record.body))
|
||||
expect(traces).toContain("procedure tenancy.org.get")
|
||||
// The deployment token, the session secret and the person's address never reach the platform's log.
|
||||
for (const secret of ["e2e-token", "ada@example.test"]) expect(logs + traces).not.toContain(secret)
|
||||
}).toPass({ timeout: 30_000 })
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What every spec does: sign in through the dev adapter, call a procedure the way the page's own
|
||||
// forms do (with the page's CSRF token), and make an organization to work in.
|
||||
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { type APIRequestContext, expect, type Page } from "@playwright/test"
|
||||
import axe from "axe-core"
|
||||
|
||||
export const unique = (prefix: string): string => `${prefix}-${randomBytes(3).toString("hex")}`
|
||||
|
||||
/** Signs in as `email` (created on first use) and lands on the home page. */
|
||||
export async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`/auth/dev-login?email=${encodeURIComponent(email)}`)
|
||||
await expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1)
|
||||
}
|
||||
|
||||
/** The CSRF token of the page the browser is on: what the page's own forms send back. */
|
||||
const csrfOf = (page: Page): Promise<string> =>
|
||||
page
|
||||
.locator('meta[name="csrf-token"]')
|
||||
.getAttribute("content")
|
||||
.then((value) => value ?? "")
|
||||
|
||||
/** Calls a procedure with the session of `page`. Throws on a refusal, with the problem's own words. */
|
||||
export async function call<T = unknown>(page: Page, procedure: string, input: unknown): Promise<T> {
|
||||
const response = await callRaw(page.request, await csrfOf(page), procedure, input)
|
||||
const body = (await response.json().catch(() => null)) as T
|
||||
if (!response.ok()) throw new Error(`${procedure} answered ${response.status()}: ${JSON.stringify(body)}`)
|
||||
return body
|
||||
}
|
||||
|
||||
export const callRaw = (request: APIRequestContext, csrf: string, procedure: string, input: unknown) =>
|
||||
request.post(`/api/v1/${procedure}`, {
|
||||
data: input,
|
||||
headers: { "x-csrf-token": csrf, "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
||||
})
|
||||
|
||||
export interface Workspace {
|
||||
org: string
|
||||
workspace: string
|
||||
project: string
|
||||
}
|
||||
|
||||
/** The signed-in user's own organization with one workspace and one project. */
|
||||
export async function makeOrganization(page: Page, prefix = "acme"): Promise<Workspace> {
|
||||
const org = unique(prefix)
|
||||
await call(page, "tenancy.org.create", { slug: org, name: `Org ${org}` })
|
||||
const workspace = (
|
||||
await call<{ slug: string }>(page, "tenancy.workspace.create", {
|
||||
org,
|
||||
slug: "main",
|
||||
name: "Main",
|
||||
visibility: "org",
|
||||
})
|
||||
).slug
|
||||
const project = (
|
||||
await call<{ slug: string }>(page, "tenancy.project.create", {
|
||||
org,
|
||||
workspace,
|
||||
slug: "review",
|
||||
name: "Review",
|
||||
moduleKey: "documents",
|
||||
})
|
||||
).slug
|
||||
return { org, workspace, project }
|
||||
}
|
||||
|
||||
/** Puts axe into the page. */
|
||||
async function injectAxe(page: Page): Promise<void> {
|
||||
await page.addScriptTag({ content: axe.source })
|
||||
}
|
||||
|
||||
/** WCAG 2.2 AA violations on the page the browser is on, as axe finds them. */
|
||||
export async function violations(page: Page): Promise<string[]> {
|
||||
await injectAxe(page)
|
||||
const results = await page.evaluate(() =>
|
||||
(
|
||||
globalThis as unknown as {
|
||||
axe: {
|
||||
run(
|
||||
context: unknown,
|
||||
options: unknown,
|
||||
): Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown[] }> }> }>
|
||||
}
|
||||
}
|
||||
).axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }),
|
||||
)
|
||||
return results.violations.map(
|
||||
(violation) => `${violation.id}: ${violation.nodes.map((node) => node.target.join(" ")).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the fake platform has recorded: the audit events and usage facts the worker sent, and the blobs it stored. */
|
||||
export interface PlatformState {
|
||||
audit: Array<{ action: string; resource_id: string | null }>
|
||||
usage: Array<Record<string, unknown>>
|
||||
blobs: Array<{ pathname: string; contentType: string; size: number }>
|
||||
otel: { logs: Array<Record<string, unknown>>; traces: Array<Record<string, unknown>> }
|
||||
}
|
||||
|
||||
export async function platformState(): Promise<PlatformState> {
|
||||
const base = process.env.E2E_PLATFORM_URL
|
||||
if (!base) throw new Error("e2e: E2E_PLATFORM_URL is set by the global setup")
|
||||
const response = await fetch(`${base}/__e2e/state`)
|
||||
if (!response.ok) throw new Error(`e2e: the fake platform answered ${response.status}`)
|
||||
return (await response.json()) as PlatformState
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Starts the stack once for the whole run and stops it after (Playwright's global setup returns the
|
||||
// teardown). The OCR the fake platform answers with is the documents module's own fixture: a three-page
|
||||
// document with four labelled lines and a drawing page the extraction leaves out, which is what the specs review.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { APP_ROOT, startStack } from "./stack"
|
||||
|
||||
export default async function globalSetup(): Promise<() => Promise<void>> {
|
||||
const document = JSON.parse(
|
||||
readFileSync(join(APP_ROOT, "modules", "documents", "__tests__", "fixtures", "ocr-document.json"), "utf8"),
|
||||
) as Record<string, unknown>
|
||||
const stack = await startStack(document)
|
||||
process.env.E2E_BASE_URL = stack.baseUrl
|
||||
process.env.E2E_PLATFORM_URL = stack.platformUrl
|
||||
return async () => {
|
||||
await stack.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The whole app, running, for the end-to-end suite: a Postgres with the three roles a linked managed
|
||||
// database exposes, the fake platform of @conusai-kit/testing (Blob, OCR jobs, audit, usage), the
|
||||
// migrate job, the worker and the web shell -- the same four things compose.yaml runs, as processes
|
||||
// on this machine so the suite needs no image build.
|
||||
//
|
||||
// Postgres is the one thing that needs Docker. The web shell runs under `next dev`, because
|
||||
// @conusai-kit/auth refuses the password-free dev sign-in the suite uses in a production start
|
||||
// (AUTH_DEV_LOGIN with NODE_ENV=production fails at boot, spec §7), which is the right refusal and
|
||||
// not one to work around.
|
||||
|
||||
import { type ChildProcess, execFileSync, spawn } from "node:child_process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
||||
import { deterministicModel } from "../../test/support/fake-model"
|
||||
|
||||
export const APP_ROOT = new URL("../..", import.meta.url).pathname
|
||||
const LOGS = join(APP_ROOT, "e2e", ".logs")
|
||||
|
||||
export const PORTS = {
|
||||
web: Number(process.env.E2E_WEB_PORT ?? 3100),
|
||||
postgres: Number(process.env.E2E_POSTGRES_PORT ?? 55432),
|
||||
}
|
||||
|
||||
export interface Stack {
|
||||
readonly baseUrl: string
|
||||
readonly platformUrl: string
|
||||
/** The fake platform, so a spec can change what its OCR reads. */
|
||||
readonly platform: Awaited<ReturnType<typeof startFakePlatform>>
|
||||
stop(): Promise<void>
|
||||
}
|
||||
|
||||
const url = (role: string): string => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${PORTS.postgres}/appdev`
|
||||
|
||||
function sh(command: string, args: string[]): string {
|
||||
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
||||
}
|
||||
|
||||
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
|
||||
const deadline = Date.now() + seconds * 1000
|
||||
for (;;) {
|
||||
try {
|
||||
if (await check()) return
|
||||
} catch {
|
||||
// not yet
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`e2e: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
|
||||
mkdirSync(LOGS, { recursive: true })
|
||||
const out = openSync(join(LOGS, `${name}.log`), "w")
|
||||
// A process group of its own, so stopping it stops what it started (next dev forks).
|
||||
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
|
||||
}
|
||||
|
||||
function stopGroup(child: ChildProcess): void {
|
||||
if (child.pid === undefined) return
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM")
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export async function startStack(document: Record<string, unknown>): Promise<Stack> {
|
||||
const processes: ChildProcess[] = []
|
||||
const name = `e2e-postgres-${randomBytes(4).toString("hex")}`
|
||||
|
||||
const platform = await startFakePlatform()
|
||||
platform.ocr.setDocument(document)
|
||||
// The model is the deterministic reader of the unit tests: the suite proves the app around a model, not a model.
|
||||
platform.ai.respond(deterministicModel)
|
||||
|
||||
sh("docker", [
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"-p",
|
||||
`127.0.0.1:${PORTS.postgres}:5432`,
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=appdev",
|
||||
"-v",
|
||||
`${join(APP_ROOT, "dev", "postgres-roles.sql")}:/docker-entrypoint-initdb.d/10-roles.sql:ro`,
|
||||
// pgvector: the run chooses the pgvector store (KNOWLEDGE_VECTOR_STORE below), whose table the
|
||||
// migrate job creates in this Postgres.
|
||||
"pgvector/pgvector:pg18",
|
||||
])
|
||||
const stopPostgres = (): void => {
|
||||
try {
|
||||
sh("docker", ["rm", "-f", name])
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// The init script creates the roles after the server first starts, then it restarts: "ready"
|
||||
// is the app's own login working, not pg_isready.
|
||||
await until("postgres", () => {
|
||||
sh("docker", ["exec", name, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
|
||||
return true
|
||||
})
|
||||
|
||||
const common: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// The fake platform is a development and test switch the app accepts in these modes alone (config/src/env.ts).
|
||||
NODE_ENV: "development",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
// What the platform injects when the environment has OTLP: the web shell sends its logs and spans to
|
||||
// the fake platform, where a spec reads them back with the request's trace id.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: `${platform.url}/otel`,
|
||||
CONUSAI_API_URL: `${platform.url}/api`,
|
||||
CONUSAI_API_TOKEN: "e2e-token",
|
||||
CONUSAI_PROJECT_ID: "42",
|
||||
// What the platform injects for usage metering (AT4.1): the fake platform keeps the usage of
|
||||
// this organization beside the audit events, which is where a spec reads both.
|
||||
CONUSAI_ORG_ID: "e2e",
|
||||
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${PORTS.web}`,
|
||||
CONUSAI_RELEASE: "e2e",
|
||||
POSTGRES_URL: url("app"),
|
||||
POSTGRES_READER_URL: url("reader"),
|
||||
KNOWLEDGE_VECTOR_STORE: "pgvector",
|
||||
AUTH_SESSION_SECRET: randomBytes(32).toString("base64url"),
|
||||
}
|
||||
delete common.POSTGRES_MIGRATE_URL
|
||||
|
||||
// The migrate job holds the owner's URL and nothing else does.
|
||||
execFileSync("node", ["apps/worker/dist/migrate.js"], {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...common, POSTGRES_MIGRATE_URL: url("owner") },
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
// The demo data, written with the kit by the same script `pnpm dev` runs: the specs that look at it
|
||||
// are proof it works, and the others each make their own organization beside it.
|
||||
execFileSync("node", ["apps/worker/dist/seed-main.js"], { cwd: APP_ROOT, env: common, stdio: "inherit" })
|
||||
|
||||
// A dev server killed mid-write leaves a cache Turbopack panics on at the next start: begin clean.
|
||||
rmSync(join(APP_ROOT, "apps", "web", ".next"), { recursive: true, force: true })
|
||||
|
||||
processes.push(
|
||||
spawnLogged("worker", "node", ["apps/worker/dist/main.js"], APP_ROOT, {
|
||||
...common,
|
||||
WORKER_HEARTBEAT_FILE: join(LOGS, "worker-heartbeat"),
|
||||
}),
|
||||
)
|
||||
processes.push(
|
||||
spawnLogged(
|
||||
"web",
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "dev", "-p", String(PORTS.web), "-H", "127.0.0.1"],
|
||||
join(APP_ROOT, "apps", "web"),
|
||||
{
|
||||
...common,
|
||||
AUTH_DEV_LOGIN: "1",
|
||||
AUTH_INSECURE_COOKIES: "1",
|
||||
},
|
||||
),
|
||||
)
|
||||
const baseUrl = `http://127.0.0.1:${PORTS.web}`
|
||||
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 180)
|
||||
await until("the worker", () => readFileSync(join(LOGS, "worker.log"), "utf8").includes("worker: serving"), 60)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
platformUrl: platform.url,
|
||||
platform,
|
||||
async stop() {
|
||||
for (const child of processes) stopGroup(child)
|
||||
await platform.close()
|
||||
stopPostgres()
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
for (const child of processes) stopGroup(child)
|
||||
await platform.close()
|
||||
stopPostgres()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig.paths.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"lib": ["ES2023", "dom"],
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"../modules/documents/__tests__/support/unzip.ts",
|
||||
"../test/support/fake-model.ts",
|
||||
"../test/support/images.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user