mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-18 03:19:19 +02:00
311 lines
9.4 KiB
TypeScript
311 lines
9.4 KiB
TypeScript
import { expect, test } from "@playwright/test"
|
|
import {
|
|
coreURL,
|
|
defaultBranchId,
|
|
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 {
|
|
authorizeMCPClient,
|
|
connectMCPClient,
|
|
documentResourceURI,
|
|
} from "../helpers/mcp"
|
|
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`),
|
|
)
|
|
// a document another workspace owns is answered exactly as one that
|
|
// does not exist. Both of these are list queries, so without the
|
|
// check the organization filter would simply match nothing and the
|
|
// denial would arrive as an empty success
|
|
expect(branches.status()).toBe(404)
|
|
|
|
const maintainers = await page.request.get(
|
|
coreURL(`/api/documents/${foreign}/maintainers`),
|
|
)
|
|
expect(maintainers.status()).toBe(404)
|
|
|
|
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()
|
|
// the query string is deliberately not pinned: logging out can
|
|
// land on /login or on /login?next=..., depending on whether the
|
|
// route guard or the sign-out redirect gets there first, and
|
|
// which one wins says nothing about the refusal this case is
|
|
// about. What follows is the assertion that matters.
|
|
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()
|
|
|
|
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()
|
|
})
|
|
|
|
test("lists only the granting workspace's documents over mcp", async ({
|
|
page,
|
|
request,
|
|
browser,
|
|
}) => {
|
|
// two tenants, plus a registration, a consent and a token
|
|
// exchange on top of the usual setup
|
|
test.slow()
|
|
|
|
await signUpWithWorkspace(page, request)
|
|
const own = documentId(page)
|
|
const ownBranch = await defaultBranchId(page, own)
|
|
|
|
const other = await signUpWithSeparateWorkspace(browser, request)
|
|
const foreign = documentId(other.page)
|
|
const foreignBranch = await defaultBranchId(other.page, foreign)
|
|
|
|
const client = await connectMCPClient(
|
|
await authorizeMCPClient(page, request),
|
|
)
|
|
const { resources } = await client.listResources()
|
|
const uris = resources.map((resource) => resource.uri)
|
|
|
|
// the token was granted by a user of the first workspace, so the
|
|
// organization it is bound to is the only one it can see
|
|
expect(uris).toContain(documentResourceURI(own, ownBranch))
|
|
expect(uris).not.toContain(documentResourceURI(foreign, foreignBranch))
|
|
|
|
await client.close()
|
|
await other.context.close()
|
|
})
|
|
|
|
test("refuses to read another workspace's document over mcp", async ({
|
|
page,
|
|
request,
|
|
browser,
|
|
}) => {
|
|
test.slow()
|
|
|
|
await signUpWithWorkspace(page, request)
|
|
const own = documentId(page)
|
|
const ownBranch = await defaultBranchId(page, own)
|
|
|
|
const other = await signUpWithSeparateWorkspace(browser, request)
|
|
const foreign = documentId(other.page)
|
|
const foreignBranch = await defaultBranchId(other.page, foreign)
|
|
|
|
const client = await connectMCPClient(
|
|
await authorizeMCPClient(page, request),
|
|
)
|
|
|
|
// the control: the same call on the token's own document has to
|
|
// come back, or the refusal below would say nothing about
|
|
// scoping and everything about a read that never works
|
|
const readable = await client.readResource({
|
|
uri: documentResourceURI(own, ownBranch),
|
|
})
|
|
expect(readable.contents).not.toHaveLength(0)
|
|
|
|
// named directly rather than picked off the listing: a scoped
|
|
// listing is no protection if the read behind it answers for
|
|
// any id it is handed
|
|
await expect(
|
|
client.readResource({
|
|
uri: documentResourceURI(foreign, foreignBranch),
|
|
}),
|
|
).rejects.toThrow()
|
|
|
|
await client.close()
|
|
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)
|
|
})
|
|
}
|
|
})
|