From 1d12e2ecc13d1348004f90f60d4268bc07891abc Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:16:25 +0200 Subject: [PATCH] add auth e2e tests --- e2e/CLAUDE.md | 30 ++++- e2e/helpers/api.ts | 32 +++++ e2e/helpers/collaboration.ts | 63 ++++++---- e2e/helpers/config.ts | 1 + e2e/helpers/realtime.ts | 66 ++++++++++ e2e/helpers/workspace.ts | 28 ++++- e2e/package.json | 5 +- e2e/pnpm-lock.yaml | 93 ++++++++++++++ e2e/tests/access.test.ts | 235 +++++++++++++++++++++++++++++++++++ 9 files changed, 522 insertions(+), 31 deletions(-) create mode 100644 e2e/helpers/api.ts create mode 100644 e2e/helpers/realtime.ts create mode 100644 e2e/tests/access.test.ts diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index cb089f05..91dabb77 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -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 diff --git a/e2e/helpers/api.ts b/e2e/helpers/api.ts new file mode 100644 index 00000000..4bc6c996 --- /dev/null +++ b/e2e/helpers/api.ts @@ -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 "-", 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 { + const cookies = await page.context().cookies() + + return cookies.map((c) => `${c.name}=${c.value}`).join("; ") +} diff --git a/e2e/helpers/collaboration.ts b/e2e/helpers/collaboration.ts index 0252c428..2e390522 100644 --- a/e2e/helpers/collaboration.ts +++ b/e2e/helpers/collaboration.ts @@ -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 { + // 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() diff --git a/e2e/helpers/config.ts b/e2e/helpers/config.ts index f2eea336..9224a58c 100644 --- a/e2e/helpers/config.ts +++ b/e2e/helpers/config.ts @@ -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` diff --git a/e2e/helpers/realtime.ts b/e2e/helpers/realtime.ts new file mode 100644 index 00000000..60347b58 --- /dev/null +++ b/e2e/helpers/realtime.ts @@ -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 { + 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((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() + }) +} diff --git a/e2e/helpers/workspace.ts b/e2e/helpers/workspace.ts index 517e3f6e..9da2f0c7 100644 --- a/e2e/helpers/workspace.ts +++ b/e2e/helpers/workspace.ts @@ -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 } +} diff --git a/e2e/package.json b/e2e/package.json index f45d0fc2..54cd39e6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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" } } diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index 33787506..f4586622 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -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: {} diff --git a/e2e/tests/access.test.ts b/e2e/tests/access.test.ts new file mode 100644 index 00000000..8976a093 --- /dev/null +++ b/e2e/tests/access.test.ts @@ -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 }[] = [ + { 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) + }) + } +})