add auth e2e tests

This commit is contained in:
swithek 2026-08-25 11:16:25 +02:00
parent 54beef4c3e
commit 1d12e2ecc1
9 changed files with 522 additions and 31 deletions

View File

@ -140,8 +140,8 @@ nothing is stubbed or short-circuited.
is filed by flow instead.
- **Shared code lives in [helpers/](helpers/), one module per concern**
(`auth`, `workspace`, `editor`, `collaboration`, `mailpit`, `i18n`,
`page`, `config`). Helpers are setup and plumbing; they do not carry
the assertion a test exists to make.
`page`, `config`, `api`, `realtime`). Helpers are setup and plumbing;
they do not carry the assertion a test exists to make.
- **`signUpWithWorkspace` is the starting point for almost everything.**
It signs up, verifies, logs in and creates a workspace, and hands back
a page with the welcome document open. Nearly every flow needs that
@ -238,8 +238,30 @@ something no cheaper tier can.
mock?** If yes, it belongs one tier down.
- **Coverage is by flow, not by branch.** `web/` owns "every exported
function is covered" and "every path gets a test"; this tier owns "the
wiring holds". Reaching for one more error-path e2e is nearly always
the wrong instinct — add it as a component test instead.
wiring holds". Reaching for one more browser-decided error-path e2e is
nearly always the wrong instinct — add it as a component test instead.
- **A negative case belongs here when the server is what decides it**,
and it is a first-class regression harness rather than a concession.
What the rule above excludes is the browser deciding — a validation
message, a disabled button. A refusal that crosses the process
boundary is the opposite: "user A cannot open user B's document" is
answered by core and nowhere else, so it meets the mock test as
squarely as the happy path beside it, and it guards a class of bug
that silently reappears the day someone drops an org filter.
- **Access denials are grouped in
[tests/access.test.ts](tests/access.test.ts)** rather than filed under
the flow each one touches. A per-feature file never reaches them: a
feature test always runs as the user who is supposed to have access,
which is why a missing check survives a suite that covers every line.
Naming follows the same rule as everywhere else — the group names the
subject, each name completes "it …" with the denial it observes.
- **A denial that does not hold yet is marked `test.fail()`**, with a
comment saying what is missing and what to delete when it is fixed.
That keeps the gap in the suite instead of in a backlog, and Playwright
turns the annotation red the day the case starts passing. Check how
long such a case takes when you add one: a test that hangs also
registers as an expected failure, so a broken probe and a real gap
look identical until you read the duration.
### A real backend, and no mocks

32
e2e/helpers/api.ts Normal file
View File

@ -0,0 +1,32 @@
import type { Page } from "@playwright/test"
import { BASE_URL } from "./config"
// coreURL builds an address for core's API through the front door. Caddy
// strips the /core prefix before proxying, so the path is written here
// exactly as core serves it.
export function coreURL(path: string): string {
return `${BASE_URL}/core${path}`
}
// documentId reads the identifier out of the URL of an open document.
// Every document page ends in "<name-slug>-<id>", and the id is the
// trailing xid the routes elsewhere assert the shape of.
export function documentId(page: Page): string {
const id = /-([a-z0-9]{20})$/.exec(page.url())?.[1]
if (!id) {
throw new Error(`no document id in the url ${page.url()}`)
}
return id
}
// sessionCookie serialises a browser context's cookies into a request
// header, so a caller outside the browser can present the same session.
// Reading them while the session is live is the only way to still hold
// the cookie after the product has invalidated it.
export async function sessionCookie(page: Page): Promise<string> {
const cookies = await page.context().cookies()
return cookies.map((c) => `${c.name}=${c.value}`).join("; ")
}

View File

@ -16,6 +16,43 @@ import { t } from "./i18n"
import { fetchInviteLink } from "./mailpit"
import { visit } from "./page"
// inviteTeamMember sends a workspace invitation from settings and hands
// back the accept link the invitee is emailed. The owner's page is left
// with every dialog closed, on whatever it was showing before.
export async function inviteTeamMember(
page: Page,
request: APIRequestContext,
email: string,
): Promise<string> {
// the sidebar's next-steps entry is only offered while the owner is
// alone in the workspace, which every fresh workspace's owner is
await page
.getByText(t("sidebar.sections.next-steps.items.invite-team-members"))
.click()
await page
.getByRole("button", { name: t("settings.workspace.invitation-button") })
.click()
await page
.getByPlaceholder(
t("settings.action-modals.workspace-invitation.email-placeholder"),
)
.fill(email)
await page
.getByRole("button", {
name: t("settings.action-modals.workspace-invitation.submit-button"),
})
.click()
// the delivered email is the proof the invitation went through; the
// invite modal has closed itself by then, leaving only settings
const inviteLink = await fetchInviteLink(request, email)
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toHaveCount(0)
return inviteLink
}
// joinAsSecondUser brings a second, distinct user into the first user's
// workspace and opens the same document in a fresh browser context: the
// owner sends an invitation from settings, and the invitee signs up,
@ -32,31 +69,7 @@ export async function joinAsSecondUser(
const credentials = newCredentials()
const documentUrl = page.url()
// the sidebar's next-steps entry is only offered while the owner is
// alone in the workspace, which every fresh workspace's owner is
await page
.getByText(t("sidebar.sections.next-steps.items.invite-team-members"))
.click()
await page
.getByRole("button", { name: t("settings.workspace.invitation-button") })
.click()
await page
.getByPlaceholder(
t("settings.action-modals.workspace-invitation.email-placeholder"),
)
.fill(credentials.email)
await page
.getByRole("button", {
name: t("settings.action-modals.workspace-invitation.submit-button"),
})
.click()
// the delivered email is the proof the invitation went through; the
// invite modal has closed itself by then, leaving only settings
const inviteLink = await fetchInviteLink(request, credentials.email)
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toHaveCount(0)
const inviteLink = await inviteTeamMember(page, request, credentials.email)
const context = await browser.newContext()
const invitee = await context.newPage()

View File

@ -2,3 +2,4 @@
// both can run at once. Changing one means changing docker-compose.yaml.
export const BASE_URL = "http://localhost:18080"
export const MAILPIT_URL = "http://localhost:18025"
export const REALTIME_WS_URL = `ws://localhost:18080/auth-realtime/hocuspocus`

66
e2e/helpers/realtime.ts Normal file
View File

@ -0,0 +1,66 @@
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from "@hocuspocus/provider"
import type { Page } from "@playwright/test"
import WebSocket from "ws"
import { sessionCookie } from "./api"
import { REALTIME_WS_URL } from "./config"
// documentAccepted opens a hocuspocus connection for documentName as the
// page's own user and reports the verdict the realtime server answers
// with. It runs the app's own client, given the app's own arguments —
// the document name is the only thing a test chooses.
//
// The provider builds its socket from a url and nothing else, so the
// session rides in a subclass that adds the cookie header: a browser
// attaches it unasked, node does not.
export async function documentAccepted(
page: Page,
documentName: string,
): Promise<boolean> {
const cookie = await sessionCookie(page)
class SessionWebSocket extends WebSocket {
constructor(url: string) {
super(url, { headers: { cookie } })
}
}
const socket = new HocuspocusProviderWebsocket({
url: REALTIME_WS_URL,
WebSocketPolyfill: SessionWebSocket,
})
return new Promise<boolean>((resolve) => {
let provider: HocuspocusProvider | null = null
const settle = (accepted: boolean): void => {
provider?.destroy()
socket.destroy()
resolve(accepted)
}
provider = new HocuspocusProvider({
name: documentName,
token: "notoken",
websocketProvider: socket,
onAuthenticated: () => {
settle(true)
},
onAuthenticationFailed: () => {
settle(false)
},
// a refusal the server closes on rather than answers
// would otherwise hang until the test budget expires
onClose: () => {
settle(false)
},
})
// the provider only wires itself up to a socket it created
// itself; one passed in has to be attached by hand, and without
// this nothing ever connects
provider.attach()
})
}

View File

@ -1,5 +1,11 @@
import { randomUUID } from "node:crypto"
import { expect, type APIRequestContext, type Page } from "@playwright/test"
import {
expect,
type APIRequestContext,
type Browser,
type BrowserContext,
type Page,
} from "@playwright/test"
import { type Credentials, signUpAndVerify, submitLoginForm } from "./auth"
import { waitForEditor } from "./editor"
import { t } from "./i18n"
@ -61,3 +67,23 @@ export async function signUpWithWorkspace(
return { credentials, workspace }
}
// signUpWithSeparateWorkspace builds a second tenant: another verified
// user with a workspace of their own, in a browser context of their own.
// Nothing links it to the caller's workspace — no invitation, no shared
// member — which is what makes it the far side of a cross-tenant test.
export async function signUpWithSeparateWorkspace(
browser: Browser,
request: APIRequestContext,
): Promise<{
context: BrowserContext
page: Page
credentials: Credentials
workspace: Workspace
}> {
const context = await browser.newContext()
const page = await context.newPage()
const { credentials, workspace } = await signUpWithWorkspace(page, request)
return { context, page, credentials, workspace }
}

View File

@ -21,14 +21,17 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@hocuspocus/provider": "^3.4.4",
"@playwright/test": "^1.62.1",
"@types/node": "^20.11.17",
"@types/ws": "^8.18.1",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-playwright": "^2.11.0",
"knip": "^6.32.2",
"prettier": "3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
"typescript-eslint": "^8.40.0",
"ws": "^8.21.3"
}
}

View File

@ -11,12 +11,18 @@ importers:
'@eslint/js':
specifier: ^9.33.0
version: 9.39.5
'@hocuspocus/provider':
specifier: ^3.4.4
version: 3.4.4(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)
'@playwright/test':
specifier: ^1.62.1
version: 1.62.1
'@types/node':
specifier: ^20.11.17
version: 20.19.43
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
eslint:
specifier: ^9.33.0
version: 9.39.5(jiti@2.7.0)
@ -38,6 +44,9 @@ importers:
typescript-eslint:
specifier: ^8.40.0
version: 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
ws:
specifier: ^8.21.3
version: 8.21.3
packages:
@ -88,6 +97,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@hocuspocus/common@3.4.4':
resolution: {integrity: sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==}
'@hocuspocus/provider@3.4.4':
resolution: {integrity: sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==}
peerDependencies:
y-protocols: ^1.0.6
yjs: ^13.6.8
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@ -108,6 +126,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@lifeomic/attempt@3.1.0':
resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
'@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@ -360,6 +381,9 @@ packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.67.0':
resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -642,6 +666,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@ -671,6 +698,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
engines: {node: '>=16'}
hasBin: true
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -837,11 +869,33 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
y-protocols@1.0.7:
resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yjs@13.6.32:
resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -913,6 +967,22 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@hocuspocus/common@3.4.4':
dependencies:
lib0: 0.2.117
'@hocuspocus/provider@3.4.4(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)':
dependencies:
'@hocuspocus/common': 3.4.4
'@lifeomic/attempt': 3.1.0
lib0: 0.2.117
ws: 8.21.3
y-protocols: 1.0.7(yjs@13.6.32)
yjs: 13.6.32
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@ -929,6 +999,8 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@lifeomic/attempt@3.1.0': {}
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
dependencies:
'@emnapi/core': 1.11.2
@ -1073,6 +1145,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.43
'@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -1374,6 +1450,8 @@ snapshots:
isexe@2.0.0: {}
isomorphic.js@0.2.5: {}
jiti@2.7.0: {}
js-yaml@4.3.1:
@ -1411,6 +1489,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lib0@0.2.117:
dependencies:
isomorphic.js: 0.2.5
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -1583,8 +1665,19 @@ snapshots:
word-wrap@1.2.5: {}
ws@8.21.3: {}
y-protocols@1.0.7(yjs@13.6.32):
dependencies:
lib0: 0.2.117
yjs: 13.6.32
yaml@2.9.0: {}
yjs@13.6.32:
dependencies:
lib0: 0.2.117
yocto-queue@0.1.0: {}
zod@4.4.3: {}

235
e2e/tests/access.test.ts Normal file
View File

@ -0,0 +1,235 @@
import { expect, test } from "@playwright/test"
import { coreURL, documentId, sessionCookie } from "../helpers/api"
import {
newCredentials,
signUpAndVerify,
submitLoginForm,
} from "../helpers/auth"
import { inviteTeamMember } from "../helpers/collaboration"
import { waitForEditor } from "../helpers/editor"
import { t } from "../helpers/i18n"
import { visit } from "../helpers/page"
import { documentAccepted } from "../helpers/realtime"
import { openSettings } from "../helpers/settings"
import {
signUpWithSeparateWorkspace,
signUpWithWorkspace,
} from "../helpers/workspace"
test.describe("access", () => {
test("keeps another workspace's document out of the api", async ({
page,
request,
browser,
}) => {
// two independent tenants, each paying a signup, a verification, a
// login and a workspace creation before the first assertion
test.slow()
await signUpWithWorkspace(page, request)
const other = await signUpWithSeparateWorkspace(browser, request)
const foreign = documentId(other.page)
// page.request carries the first user's own session cookies, so
// this is their browser asking core for a document belonging to a
// workspace they have never been a member of
const branches = await page.request.get(
coreURL(`/api/documents/${foreign}/branches`),
)
// 200 is what these answer today, not what they should. Both are
// list queries whose organization filter simply matches nothing,
// so a denial arrives as an empty success — a document another
// workspace owns belongs behind a 404, as deleting one already is.
expect(branches.status()).toBe(200)
expect(await branches.json()).toEqual([])
const maintainers = await page.request.get(
coreURL(`/api/documents/${foreign}/maintainers`),
)
expect(maintainers.status()).toBe(200)
expect(await maintainers.json()).toEqual([])
await other.context.close()
})
test("leaves another workspace's document intact after a delete attempt", async ({
page,
request,
browser,
}) => {
test.slow()
await signUpWithWorkspace(page, request)
const other = await signUpWithSeparateWorkspace(browser, request)
const foreign = documentId(other.page)
const foreignUrl = other.page.url()
const deletion = await page.request.delete(
coreURL(`/api/documents/${foreign}`),
)
expect(deletion.status()).toBe(404)
// the owner reloads: a deletion that had gone through would leave
// them redirected away from a document that no longer exists
await visit(other.page, foreignUrl)
await expect(other.page).toHaveURL(foreignUrl)
await waitForEditor(other.page)
await other.context.close()
})
test("refuses a revoked invitation", async ({ page, request, browser }) => {
// a signup and verification on each side, plus an invitation sent
// and withdrawn between them
test.slow()
const { workspace } = await signUpWithWorkspace(page, request)
const invitee = newCredentials()
const inviteLink = await inviteTeamMember(page, request, invitee.email)
// withdraw it before it is ever used. A pending invitation is
// listed among the members, so it is revoked the same way a member
// is removed
const settings = await openSettings(page, workspace.name)
const row = settings.locator("tr", { hasText: invitee.email })
await expect(row).toContainText(t("settings.workspace.invited-label"))
await row
.getByRole("button", {
name: t("settings.workspace.members-option-button-screen-reader-hint"),
})
.click()
await page
.getByRole("menuitem", {
name: t("settings.workspace.member-options.delete.title"),
})
.click()
await page
.getByRole("button", {
name: t(
"settings.action-modals.workspace-member-removal.submit-button",
),
})
.click()
await expect(row).toHaveCount(0)
// the invitee still holds the emailed link, and it is the server
// that has to refuse it
const context = await browser.newContext()
const joiner = await context.newPage()
await signUpAndVerify(joiner, request, invitee)
await submitLoginForm(joiner, invitee)
await expect(joiner).toHaveURL(/\/welcome$/, { timeout: 15_000 })
await visit(joiner, inviteLink)
await joiner
.getByRole("button", {
name: t("onboarding.accept-invite.accept-button"),
})
.click()
await expect(
joiner.getByText(t("onboarding.accept-invite.errors.accept-failed")),
).toBeVisible()
await expect(joiner).toHaveURL(/\/accept-invite\?/)
await context.close()
})
test("rejects a session that has been signed out", async ({
page,
request,
}) => {
const { workspace } = await signUpWithWorkspace(page, request)
// captured while the session is still live. Signing out clears the
// browser's copy, and the point of the case is that the server
// refuses the cookie even when a caller still holds one
const cookie = await sessionCookie(page)
const before = await request.get(coreURL("/api/capabilities"), {
headers: { cookie },
})
expect(before.status()).toBe(200)
await page.getByRole("button", { name: workspace.name }).click()
await page
.getByRole("menuitem", { name: t("sidebar.header.log-out") })
.click()
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 })
const after = await request.get(coreURL("/api/capabilities"), {
headers: { cookie },
})
expect(after.status()).toBe(401)
})
test("keeps another workspace's document off the realtime socket", async ({
page,
request,
browser,
}) => {
test.slow()
// this one does not hold yet, and the annotation is here to keep
// that visible rather than to excuse it: the realtime server
// authenticates a connection by asking core whether the document
// exists, over core's internal surface, which answers without
// filtering by organization. Any signed-in user therefore reaches
// any document whose id they know, over the socket that carries
// the content itself — the HTTP surface above is scoped and this
// one is not. Delete this line when the check learns about
// organizations; it will then fail for passing unexpectedly.
test.fail()
await signUpWithWorkspace(page, request)
const own = documentId(page)
const other = await signUpWithSeparateWorkspace(browser, request)
const foreign = documentId(other.page)
// the control. If the probe cannot get into a document the user
// does own, the rejection below would prove nothing about
// authorization and everything about a broken probe
expect(await documentAccepted(page, `${own}-default`)).toBe(true)
// the same browser, the same session, asking for a document in a
// workspace it has never been a member of. A document id is all
// this takes, and every workspace shows its own in the address
// bar of every page
expect(await documentAccepted(page, `${foreign}-default`)).toBe(false)
await other.context.close()
})
const BEARERS: { name: string; headers: Record<string, string> }[] = [
{ name: "no authorization header", headers: {} },
{
name: "a malformed authorization header",
headers: { authorization: "Bearer" },
},
{
name: "an unissued bearer token",
headers: { authorization: "Bearer not-a-real-access-token" },
},
]
for (const bearer of BEARERS) {
test(`refuses an mcp call with ${bearer.name}`, async ({ request }) => {
const response = await request.post(coreURL("/api/mcp"), {
headers: {
...bearer.headers,
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
data: {
jsonrpc: "2.0",
id: 1,
method: "tools/list",
},
})
expect(response.status()).toBe(401)
})
}
})