File
Blob: tests/e2e/specs/25-guest-workspace-shell.spec.ts
| 1 | import { test, expect, createTestWorkspace, createTestPage, loginAsFreshTesseraUser } from "../fixtures/bland-test"; |
| 2 | import type { Page as PlaywrightPage } from "@playwright/test"; |
| 3 | |
| 4 | async function createGuestInvite( |
| 5 | page: PlaywrightPage, |
| 6 | accessToken: string, |
| 7 | workspaceId: string, |
| 8 | ): Promise<{ token: string }> { |
| 9 | const res = await page.request.post(`/api/v1/workspaces/${workspaceId}/invite`, { |
| 10 | data: { role: "guest" }, |
| 11 | headers: { Authorization: `Bearer ${accessToken}` }, |
| 12 | }); |
| 13 | if (!res.ok()) throw new Error(`Failed to create invite: ${res.status()}`); |
| 14 | const data = (await res.json()) as { invite: { token: string } }; |
| 15 | return { token: data.invite.token }; |
| 16 | } |
| 17 | |
| 18 | async function shareWithUserByEmail( |
| 19 | page: PlaywrightPage, |
| 20 | accessToken: string, |
| 21 | pageId: string, |
| 22 | granteeEmail: string, |
| 23 | permission: "view" | "edit", |
| 24 | ): Promise<void> { |
| 25 | const res = await page.request.post(`/api/v1/pages/${pageId}/share`, { |
| 26 | data: { grantee_type: "user", grantee_email: granteeEmail, permission }, |
| 27 | headers: { Authorization: `Bearer ${accessToken}` }, |
| 28 | }); |
| 29 | if (!res.ok()) throw new Error(`Failed to share with user: ${res.status()} ${await res.text()}`); |
| 30 | } |
| 31 | |
| 32 | async function signInAndAcceptInvite( |
| 33 | page: PlaywrightPage, |
| 34 | token: string, |
| 35 | identity: { sub: string; email: string; name: string }, |
| 36 | ): Promise<{ accessToken: string }> { |
| 37 | return loginAsFreshTesseraUser(page, identity, `/invite/${token}?accept=1`); |
| 38 | } |
| 39 | |
| 40 | test.describe("guest workspace shell", () => { |
| 41 | test("guest with zero visible pages sees the restricted empty state, not the create CTA", async ({ |
| 42 | authenticatedPage: { page: ownerPage, accessToken: ownerToken }, |
| 43 | browser, |
| 44 | }) => { |
| 45 | const ownerWorkspace = await createTestWorkspace(ownerPage, ownerToken, "Empty Guest Workspace"); |
| 46 | const invite = await createGuestInvite(ownerPage, ownerToken, ownerWorkspace.workspaceId); |
| 47 | |
| 48 | const guestContext = await browser.newContext(); |
| 49 | const guestPage = await guestContext.newPage(); |
| 50 | |
| 51 | const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; |
| 52 | const guestEmail = `e2e-guest-empty-${suffix}@example.com`; |
| 53 | const guestSub = `e2e-guest-empty-sub-${suffix}`; |
| 54 | const guestName = `Guest Empty ${suffix}`; |
| 55 | |
| 56 | await signInAndAcceptInvite(guestPage, invite.token, { sub: guestSub, email: guestEmail, name: guestName }); |
| 57 | |
| 58 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}`); |
| 59 | await expect(guestPage.getByText("Nothing here yet.")).toBeVisible({ timeout: 15_000 }); |
| 60 | await expect(guestPage.getByRole("button", { name: /create first page/i })).toHaveCount(0); |
| 61 | |
| 62 | await guestContext.close(); |
| 63 | }); |
| 64 | |
| 65 | test("guest lands in the workspace shell without create CTA, AI affordances, or redirect loop", async ({ |
| 66 | authenticatedPage: { page: ownerPage, accessToken: ownerToken }, |
| 67 | browser, |
| 68 | }) => { |
| 69 | const ownerWorkspace = await createTestWorkspace(ownerPage, ownerToken, "Guest Host Workspace"); |
| 70 | const sharedPage = await createTestPage(ownerPage, ownerToken, "Shared With Guest", ownerWorkspace); |
| 71 | |
| 72 | const invite = await createGuestInvite(ownerPage, ownerToken, ownerWorkspace.workspaceId); |
| 73 | |
| 74 | const guestContext = await browser.newContext(); |
| 75 | const guestPage = await guestContext.newPage(); |
| 76 | |
| 77 | const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; |
| 78 | const guestEmail = `e2e-guest-${suffix}@example.com`; |
| 79 | const guestSub = `e2e-guest-sub-${suffix}`; |
| 80 | const guestName = `Guest ${suffix}`; |
| 81 | |
| 82 | await signInAndAcceptInvite(guestPage, invite.token, { sub: guestSub, email: guestEmail, name: guestName }); |
| 83 | // Grant the guest view access to the canonical page so the page actually |
| 84 | // loads for them. Without this share grant, a guest hits 404 and the |
| 85 | // "Summarize/Ask absent" assertion trivially passes on the error state. |
| 86 | await shareWithUserByEmail(ownerPage, ownerToken, sharedPage.pageId, guestEmail, "view"); |
| 87 | |
| 88 | const pageErrors: string[] = []; |
| 89 | guestPage.on("pageerror", (err) => pageErrors.push(err.message)); |
| 90 | |
| 91 | // The guest must land in the workspace shell (not /), and the shell must |
| 92 | // never surface the "Create first page" CTA — guests cannot create pages. |
| 93 | // With a shared page in their visible tree, the workspace index renders |
| 94 | // the populated state ("Pick a page...") instead of the empty state. |
| 95 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}`); |
| 96 | |
| 97 | await expect(guestPage).toHaveURL(new RegExp(`/${ownerWorkspace.workspaceSlug}(/|$)`)); |
| 98 | await expect(guestPage.getByRole("button", { name: /create first page/i })).toHaveCount(0); |
| 99 | await expect(guestPage.getByRole("button", { name: /^\+ Page$|New page|Add page/i })).toHaveCount(0); |
| 100 | |
| 101 | // Default shell shows the Settings link so the guest can reach Leave |
| 102 | // Workspace. The Invite section inside settings is writer-only. |
| 103 | const settingsLink = guestPage.getByLabel("Settings", { exact: true }); |
| 104 | await expect(settingsLink).toBeVisible(); |
| 105 | await settingsLink.click(); |
| 106 | await guestPage.waitForURL(`**/${ownerWorkspace.workspaceSlug}/settings`, { timeout: 10_000 }); |
| 107 | await expect(guestPage.getByRole("heading", { name: "Workspace Settings" })).toBeVisible(); |
| 108 | await expect(guestPage.getByRole("heading", { name: "Leave workspace" })).toBeVisible(); |
| 109 | await expect(guestPage.getByRole("heading", { name: /^Invite$/ })).toHaveCount(0); |
| 110 | |
| 111 | // Navigate to the shared canonical page. The guest has page-share access |
| 112 | // (via the membership + page_shares walk in permissions.ts), but the AI |
| 113 | // editor toolbar is role-gated: guests do not see rewrite/generate/ask. |
| 114 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}/${sharedPage.pageId}`); |
| 115 | // The page must actually render — the editor (tiptap) and title input |
| 116 | // both become visible once the page is loaded. This confirms the guest |
| 117 | // reached the page rather than an error state. |
| 118 | await expect(guestPage.locator("main .tiptap")).toBeVisible({ timeout: 30_000 }); |
| 119 | await expect(guestPage.locator("main textarea[placeholder='Untitled']")).toHaveValue("Shared With Guest", { |
| 120 | timeout: 10_000, |
| 121 | }); |
| 122 | |
| 123 | // Toolbar affordance check: AI buttons are not rendered for guests. |
| 124 | await expect(guestPage.getByRole("button", { name: /Summarize/i })).toHaveCount(0); |
| 125 | await expect(guestPage.getByRole("button", { name: /Ask/i })).toHaveCount(0); |
| 126 | |
| 127 | // Navigate back to /$wsSlug and confirm the shell does not re-bounce the |
| 128 | // guest through the root gateway (loop guard from bug1 §1a + bug2 §3). |
| 129 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}`); |
| 130 | await expect(guestPage).toHaveURL(new RegExp(`/${ownerWorkspace.workspaceSlug}(/|$)`)); |
| 131 | |
| 132 | expect(pageErrors).toEqual([]); |
| 133 | |
| 134 | await guestContext.close(); |
| 135 | }); |
| 136 | |
| 137 | test("guest can leave the workspace and is routed back to /", async ({ |
| 138 | authenticatedPage: { page: ownerPage, accessToken: ownerToken }, |
| 139 | browser, |
| 140 | }) => { |
| 141 | const ownerWorkspace = await createTestWorkspace(ownerPage, ownerToken, "Guest Exit Workspace"); |
| 142 | const invite = await createGuestInvite(ownerPage, ownerToken, ownerWorkspace.workspaceId); |
| 143 | |
| 144 | const guestContext = await browser.newContext(); |
| 145 | const guestPage = await guestContext.newPage(); |
| 146 | |
| 147 | const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; |
| 148 | const guestEmail = `e2e-guest-exit-${suffix}@example.com`; |
| 149 | const guestSub = `e2e-guest-exit-sub-${suffix}`; |
| 150 | const guestName = `Guest Exit ${suffix}`; |
| 151 | |
| 152 | await signInAndAcceptInvite(guestPage, invite.token, { sub: guestSub, email: guestEmail, name: guestName }); |
| 153 | |
| 154 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}/settings`); |
| 155 | await expect(guestPage.getByRole("heading", { name: "Leave workspace" })).toBeVisible({ timeout: 10_000 }); |
| 156 | |
| 157 | guestPage.once("dialog", (dialog) => dialog.accept()); |
| 158 | await guestPage.getByRole("button", { name: /Leave workspace/ }).click(); |
| 159 | |
| 160 | // Confirm dialog is the custom `confirm()` component. Click the Leave |
| 161 | // button inside that dialog when it appears. |
| 162 | const confirmLeave = guestPage.getByRole("button", { name: /^Leave$/ }); |
| 163 | if (await confirmLeave.isVisible().catch(() => false)) { |
| 164 | await confirmLeave.click(); |
| 165 | } |
| 166 | |
| 167 | // On success we route to /. Because the test user in TEST_CREDENTIALS owns |
| 168 | // the seed workspace, root routing may either land on "/" (no workspaces) |
| 169 | // or redirect into the seed workspace for the authenticated fixture user — |
| 170 | // but the guest user we just created has no workspaces of their own, so |
| 171 | // the root view resolves to the empty-workspace or shared-inbox surface. |
| 172 | await guestPage.waitForURL((url) => !url.pathname.startsWith(`/${ownerWorkspace.workspaceSlug}`), { |
| 173 | timeout: 10_000, |
| 174 | }); |
| 175 | |
| 176 | // Membership is gone; hitting the workspace shell now redirects. |
| 177 | await guestPage.goto(`/${ownerWorkspace.workspaceSlug}`); |
| 178 | await expect |
| 179 | .poll(() => new URL(guestPage.url()).pathname, { timeout: 10_000 }) |
| 180 | .not.toBe(`/${ownerWorkspace.workspaceSlug}`); |
| 181 | |
| 182 | await guestContext.close(); |
| 183 | }); |
| 184 | }); |