Skip to content
File

Blob: tests/e2e/specs/25-guest-workspace-shell.spec.ts

typescript185 lines
1import { test, expect, createTestWorkspace, createTestPage, loginAsFreshTesseraUser } from "../fixtures/bland-test";
2import type { Page as PlaywrightPage } from "@playwright/test";
3 
4async 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 
18async 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 
32async 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 
40test.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});