@@ -0,0 +1,628 @@
|
||||
// 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 { moduleSelected } from "../modules/inclusion"
|
||||
import { call, makeOrganization, openScope, platformState, signIn, unique, violations } from "./support/app"
|
||||
|
||||
const base = `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`
|
||||
|
||||
async function chooseShellOption(page: Page, label: string, option: string): Promise<void> {
|
||||
await page.getByRole("button", { name: new RegExp(`${label}$`) }).click()
|
||||
await page.getByRole("option", { name: option, exact: true }).click()
|
||||
}
|
||||
|
||||
/** 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, where the shell and the sign-in page put them. */
|
||||
const alerts = (page: Page) => page.getByRole("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.getByRole("group", { name: "Language" }).getByRole("button", { name: "Lietuvių" }).click()
|
||||
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("the privacy choices leave a first visit's sign-in form reachable, by pointer and by keyboard", async ({
|
||||
browser,
|
||||
}) => {
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
const context = await browser.newContext({ baseURL: base, viewport })
|
||||
const page = await context.newPage()
|
||||
await page.goto("/auth/sign-in")
|
||||
await expect(page.getByRole("region", { name: "Privacy choices" })).toBeVisible()
|
||||
|
||||
// Tabbing to the field and to the button brings each out from under the choices (WCAG 2.2, 2.4.11).
|
||||
for (const control of [
|
||||
page.getByLabel("E-mail address"),
|
||||
page.getByRole("main").getByRole("button", { name: "Sign in" }),
|
||||
]) {
|
||||
await control.focus()
|
||||
const hit = await control.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2) === element
|
||||
})
|
||||
expect(hit, `${viewport.width}x${viewport.height}`).toBe(true)
|
||||
}
|
||||
|
||||
// And nobody has to answer before they sign in.
|
||||
await page.getByLabel("E-mail address").fill("ada@example.test")
|
||||
await page.getByRole("main").getByRole("button", { name: "Sign in" }).click()
|
||||
await expect(page).not.toHaveURL(/\/auth\/sign-in/)
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a privacy choice applies in place; only a new yes to session replay takes a new document", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({ baseURL: base })
|
||||
const page = await context.newPage()
|
||||
await page.goto("/auth/sign-in")
|
||||
const choices = page.getByRole("region", { name: "Privacy choices" })
|
||||
const mark = () =>
|
||||
page.evaluate(() => {
|
||||
;(window as { sameDocument?: boolean }).sameDocument = true
|
||||
})
|
||||
const sameDocument = () => page.evaluate(() => (window as { sameDocument?: boolean }).sameDocument === true)
|
||||
|
||||
// No: the observer is told at once, the banner closes, and the document stays.
|
||||
await mark()
|
||||
await choices.getByRole("button", { name: "Reject analytics and replay" }).click()
|
||||
await expect(choices).toHaveCount(0)
|
||||
expect(await sameDocument()).toBe(true)
|
||||
|
||||
// Yes to analytics alone: the same.
|
||||
await page.getByRole("button", { name: "Privacy choices" }).click()
|
||||
await choices.getByRole("button", { name: "Accept analytics" }).click()
|
||||
await expect(choices).toHaveCount(0)
|
||||
expect(await sameDocument()).toBe(true)
|
||||
|
||||
// Yes to replay, which loads only with the observer at a document's start.
|
||||
await page.getByRole("button", { name: "Privacy choices" }).click()
|
||||
await choices.getByRole("checkbox", { name: /Also allow session replay/ }).check()
|
||||
await Promise.all([page.waitForEvent("load"), choices.getByRole("button", { name: "Accept analytics" }).click()])
|
||||
expect(await sameDocument()).toBe(false)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test("the document says when it is interactive: the server never does, the browser does once hydrated", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A press before React hydrates is lost (a tab stays unselected), so a test waits for this (`ready` of support/app).
|
||||
const served = await (await page.request.get("/auth/sign-in")).text()
|
||||
expect(served).not.toContain("data-hydrated")
|
||||
await page.goto("/auth/sign-in")
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hydrated", "true")
|
||||
})
|
||||
|
||||
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,
|
||||
}, testInfo) => {
|
||||
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.screenshot({ path: testInfo.outputPath("organization-chooser.png") })
|
||||
await page.getByRole("link", { name: "Acme Review", exact: true }).press("Enter")
|
||||
await expect(page).toHaveURL(/\/acme-review$/)
|
||||
|
||||
await page.goto("/acme-review")
|
||||
await expect(page.locator("[data-tenant-brand]")).toHaveCount(0)
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
await openScope(page)
|
||||
await page.getByRole("menuitemradio", { name: "Northwind Traders" }).click()
|
||||
await expect(page).toHaveURL(/\/northwind$/)
|
||||
await expect(page.locator('[data-tenant-brand="#1a1a8c"]')).toHaveCount(1)
|
||||
await expect(page.locator('[data-guide="frame.scope"]')).toContainText("Northwind")
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// A menu is a portal, and it keeps the organization's colours.
|
||||
await openScope(page)
|
||||
const menu = page.getByRole("menu")
|
||||
await expect(menu).toBeVisible()
|
||||
const primary = await menu.evaluate((element) => getComputedStyle(element).getPropertyValue("--primary"))
|
||||
const root = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--primary"))
|
||||
expect(primary.trim()).not.toBe(root.trim())
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
// 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, Knowledge retrieval, Agent run status and its workspace reads, and Support's
|
||||
// project processing status (the S2.6 reference tool, selected in the e2e app), which opt in on purpose;
|
||||
// no other read or write of any module (test/mcp.test.ts asserts the list with only Agent and Knowledge selected).
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
"agent.run.status",
|
||||
"assistant.workspace.openProject",
|
||||
"assistant.workspace.projects",
|
||||
"knowledge.search",
|
||||
"notes.note.list",
|
||||
"support.projectProcessingStatus.get", // kit:support
|
||||
])
|
||||
|
||||
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")
|
||||
await expect(page.locator('[data-slot="app-state"] [data-slot="empty"]')).toBeVisible()
|
||||
await expect(page.locator(".app-shell-header")).toBeHidden()
|
||||
await expect(page.getByRole("link", { name: "Back to the start" })).toBeVisible()
|
||||
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: 2, 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("Organization name", { exact: true }).fill("Studio")
|
||||
await page.getByLabel("Address", { exact: true }).fill(slug)
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/general$`))
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
await page.goto(`/${slug}/workspaces`)
|
||||
await expect(page.getByRole("heading", { level: 2, name: "Workspaces" })).toBeVisible()
|
||||
await page.locator('[data-slot="app-frame"]').evaluate((frame) => {
|
||||
;(frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate = true
|
||||
})
|
||||
await page.getByRole("button", { name: "Create a workspace", exact: true }).click()
|
||||
await page.getByLabel("Name", { exact: true }).fill("Research")
|
||||
await page.getByLabel("Short name").fill("research")
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click()
|
||||
await expect(page.getByRole("link", { name: "Research" })).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-slot="app-frame"]')
|
||||
.evaluate((frame) => (frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate),
|
||||
).toBe(true)
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// A workspace cannot take the name of a page of the shell.
|
||||
await page.getByRole("button", { name: "Create a workspace", exact: true }).click()
|
||||
await page.getByLabel("Name", { exact: true }).fill("Members")
|
||||
await page.getByLabel("Short name").fill("members")
|
||||
await page.getByRole("button", { name: "Create", exact: true }).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.locator('[data-slot="app-frame"]').evaluate((frame) => {
|
||||
;(frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate = true
|
||||
})
|
||||
await page.getByRole("button", { name: "Create a project", exact: true }).click()
|
||||
await page.getByLabel("Name", { exact: true }).fill("Field notes")
|
||||
await page.getByLabel("Short name").fill("field-notes")
|
||||
await page.getByRole("button", { name: /Module/ }).click()
|
||||
await page.getByRole("option", { name: "notes" }).click()
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="form-panel"]')).toHaveCount(0)
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-slot="app-frame"]')
|
||||
.evaluate((frame) => (frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate),
|
||||
).toBe(true)
|
||||
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()
|
||||
// The project's sections are links in the sidebar, after the project picker.
|
||||
const sidebar = page.getByRole("navigation", { name: "Main navigation" })
|
||||
await expect(sidebar.getByRole("link", { name: "Notes", exact: true })).toBeVisible()
|
||||
await expect(sidebar.getByRole("link", { name: "Notes", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
new RegExp(`/${slug}/research/field-notes/notes$`),
|
||||
)
|
||||
await expect(sidebar.getByRole("link", { name: "Documents" })).toHaveCount(moduleSelected("documents") ? 1 : 0)
|
||||
await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test("an invitation: made from Members with a copyable link, opened signed out, joined into the workspace, and every other state worded", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const owner = await person(browser, `${unique("owner")}@example.test`)
|
||||
// An address of this run's own, so the case passes on a database an earlier run used.
|
||||
const invited = `${unique("invited")}@example.test`
|
||||
const slug = unique("guild")
|
||||
await call(owner.page, "tenancy.org.create", { slug, name: "Guild" })
|
||||
|
||||
// Settings > Members: the address and the role, and the link at once, with Copy and when it stops working.
|
||||
await owner.page.context().grantPermissions(["clipboard-read", "clipboard-write"])
|
||||
await owner.page.goto(`/${slug}/members`)
|
||||
await owner.page.getByLabel("E-mail address").fill(invited)
|
||||
await owner.page.getByRole("button", { name: "Invite", exact: true }).click()
|
||||
await expect(owner.page.getByText(`${invited} is invited. Send them this link:`)).toBeVisible()
|
||||
await expect(owner.page.getByText(`It works once, for ${invited} only, until`, { exact: false })).toBeVisible()
|
||||
const link = await owner.page.getByLabel("Invitation link").inputValue()
|
||||
expect(link).toMatch(new RegExp(`/invitations/${slug}/[A-Za-z0-9_-]{20,}$`))
|
||||
await owner.page.getByRole("button", { name: "Copy link" }).click()
|
||||
await expect(owner.page.getByText("The link is copied.")).toBeVisible()
|
||||
expect(await owner.page.evaluate(() => navigator.clipboard.readText())).toBe(link)
|
||||
expect(await violations(owner.page)).toEqual([])
|
||||
await expect(owner.page.getByRole("rowheader", { name: invited })).toBeVisible()
|
||||
const path = new URL(link).pathname
|
||||
|
||||
// Signed out: the first page's layout says who it is for, as what, and until when; 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: "You are invited" })).toBeVisible()
|
||||
await expect(guest.getByText(`${invited} is invited to join Guild as Editor.`)).toBeVisible()
|
||||
expect(await violations(guest)).toEqual([])
|
||||
|
||||
// Signed in as somebody else: told who the link is for, and no accept button.
|
||||
const other = await person(browser, `${unique("other")}@example.test`)
|
||||
await other.page.goto(path)
|
||||
await expect(other.page.getByText(`This invitation is for ${invited}.`)).toBeVisible()
|
||||
await expect(other.page.getByRole("button", { name: "Accept the invitation" })).toHaveCount(0)
|
||||
await other.context.close()
|
||||
|
||||
// "Create your account": the sign-in starts with the invited address and lands in the workspace, as a member.
|
||||
await guest.getByRole("button", { name: "Create your account" }).click()
|
||||
await expect(guest.getByLabel("E-mail address")).toHaveValue(invited)
|
||||
await guest.getByRole("button", { name: "Sign in" }).click()
|
||||
await expect(guest).toHaveURL(new RegExp(`/${slug}/general$`))
|
||||
const me = await call<{ organizations: Array<{ slug: string; role: string }> }>(guest, "tenancy.me.get", undefined)
|
||||
expect(me.organizations).toEqual([{ slug, name: "Guild", role: "member" }])
|
||||
|
||||
// The same link again: used. In Lithuanian, too.
|
||||
await guest.goto(path)
|
||||
await expect(alerts(guest)).toContainText("This invitation has already been used.")
|
||||
await guest.getByRole("group", { name: "Language" }).getByRole("button", { name: "Lietuvių" }).click()
|
||||
await expect(alerts(guest)).toContainText("Šis kvietimas jau panaudotas.")
|
||||
expect(await violations(guest)).toEqual([])
|
||||
await guestContext.close()
|
||||
|
||||
// A new link replaces the old one, which then says it was revoked; a revoked one says so too.
|
||||
await owner.page.getByLabel("E-mail address").fill("later@example.test")
|
||||
await owner.page.getByRole("button", { name: "Invite", exact: true }).click()
|
||||
await expect(owner.page.getByText("later@example.test is invited. Send them this link:")).toBeVisible()
|
||||
const first = await owner.page.getByLabel("Invitation link").inputValue()
|
||||
await owner.page.getByRole("button", { name: "New link for later@example.test" }).click()
|
||||
const second = await owner.page.getByLabel("Invitation link").inputValue()
|
||||
expect(second).not.toBe(first)
|
||||
const stranger = await (await browser.newContext({ baseURL: base })).newPage()
|
||||
await stranger.goto(new URL(first).pathname)
|
||||
await expect(alerts(stranger)).toContainText("This invitation was revoked.")
|
||||
await owner.page.getByRole("button", { name: "Revoke the invitation to later@example.test" }).click()
|
||||
await expect(
|
||||
owner.page.getByRole("status").filter({ hasText: "The invitation to later@example.test is revoked." }),
|
||||
).toBeVisible()
|
||||
await stranger.goto(new URL(second).pathname)
|
||||
await expect(alerts(stranger)).toContainText("This invitation was revoked.")
|
||||
await stranger.context().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 = "ada@example.test"
|
||||
const first = await person(browser, email)
|
||||
await first.page.goto("/")
|
||||
await expect(first.page.getByRole("heading", { level: 1, name: "Organizations" })).toBeVisible()
|
||||
// The language changes in the same document: the loaders run again under the new cookie, nothing reloads.
|
||||
await first.page.evaluate(() => {
|
||||
;(window as { sameDocument?: boolean }).sameDocument = true
|
||||
})
|
||||
await chooseShellOption(first.page, "Language", "Lietuvių")
|
||||
await expect(first.page.getByRole("heading", { level: 1, name: "Organizacijos" })).toBeVisible()
|
||||
await expect(first.page.locator("html")).toHaveAttribute("lang", "lt")
|
||||
expect(await first.page.evaluate(() => (window as { sameDocument?: boolean }).sameDocument)).toBe(true)
|
||||
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 chooseShellOption(second.page, "Tema", "Šviesi")
|
||||
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 })
|
||||
})
|
||||
Reference in New Issue
Block a user