mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add auth e2e tests
This commit is contained in:
parent
54beef4c3e
commit
1d12e2ecc1
|
|
@ -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
32
e2e/helpers/api.ts
Normal 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("; ")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
66
e2e/helpers/realtime.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
235
e2e/tests/access.test.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user