diff --git a/web/e2e/lib/permission-walk.ts b/web/e2e/lib/permission-walk.ts new file mode 100644 index 000000000..c9730b387 --- /dev/null +++ b/web/e2e/lib/permission-walk.ts @@ -0,0 +1,248 @@ +import { request, type APIRequestContext, type BrowserContext } from '@playwright/test'; +import { suiteFixture, quietCrossActorToasts } from '../fixtures'; + +/** + * Multi-account permission-affordance walk: the fixture (TASK-2866 U1). + * + * HT-1157 is BUG-984's quality gate. It walks one workspace as each effective + * permission shape and checks that no forbidden affordance renders. This module + * seeds that world: a workspace of its own and one account per shape, each + * holding its own API token. + * + * Why its own workspace: specs on the shared e2e workspace starve under load + * (the delta-sync precedent), and the walk mutates membership and grants. + * Why a token per account, not a session: sessions are User-Agent bound + * (see fixtures.ts), so a session minted in node is refused by the browser. + * Each account logs in ONCE from node, only to mint that token, because + * minting requires an interactive session (BUG-2890). + * + * Every name carries a per-seed tag, so a rerun against a reused server, or + * a second worker seeding its own world, never collides. + */ + +export type AccountKey = + | 'owner' + | 'editor' + | 'viewer' + | 'viewerTasksEdit' + | 'guestItemEdit' + | 'guestPrecedence' + | 'editorSpecific'; + +/** The HT-1157 shapes, in its order (the 7th is its optional case). */ +export const ACCOUNT_KEYS: readonly AccountKey[] = [ + 'owner', + 'editor', + 'viewer', + 'viewerTasksEdit', + 'guestItemEdit', + 'guestPrecedence', + 'editorSpecific' +]; + +export interface WalkAccount { + key: AccountKey; + email: string; + username: string; + userId: string; + token: string; +} + +export interface WalkItem { + slug: string; + ref: string; + title: string; +} + +export interface PermissionWalk { + baseURL: string; + workspaceSlug: string; + /** Web routes are /{owner username}/{workspace}, whoever is viewing. */ + workspacePath: string; + accounts: Record; + tasks: WalkItem[]; + ideas: WalkItem[]; + /** + * The task both guests are granted: item-EDIT for guestItemEdit, and + * item-VIEW for guestPrecedence, whose collection-EDIT grant on Tasks + * must lose to it (the precedence case). + */ + grantedTask: WalkItem; +} + +const PASSWORD = 'Playwright-Walk-2026!'; + +async function expectOk(resp: Awaited>, what: string) { + if (!resp.ok()) { + throw new Error(`permission-walk seed: ${what} failed (${resp.status()}): ${await resp.text()}`); + } + return resp; +} + +function bearer(baseURL: string, token: string) { + return request.newContext({ baseURL, extraHTTPHeaders: { Authorization: `Bearer ${token}` } }); +} + +async function mintAccount( + baseURL: string, + admin: APIRequestContext, + key: AccountKey, + tag: string +): Promise { + const username = `walk${key.toLowerCase()}${tag}`; + const email = `${username}@example.com`; + // Admin-created accounts are verified, so they can log in at once. + await expectOk( + await admin.post('/api/v1/auth/register', { + data: { email, username, name: `Walk ${key}`, password: PASSWORD } + }), + `register ${key}` + ); + + const session = await request.newContext({ baseURL }); + try { + await expectOk( + await session.post('/api/v1/auth/login', { data: { email, password: PASSWORD } }), + `login ${key}` + ); + const state = await session.storageState(); + const csrf = state.cookies.find((c) => c.name === 'pad_csrf' || c.name === '__Host-pad_csrf'); + if (!csrf) throw new Error(`permission-walk seed: login ${key} issued no pad_csrf cookie`); + // No workspace_id: guests belong to no workspace, and a token scoped + // to one would decide what the walk measures. + const tokenResp = await expectOk( + await session.post('/api/v1/auth/tokens', { + headers: { 'X-CSRF-Token': csrf.value }, + data: { name: `walk-${key}`, expires_in: 1 } + }), + `token ${key}` + ); + const { token } = (await tokenResp.json()) as { token?: string }; + const me = (await (await expectOk(await session.get('/api/v1/auth/me'), `me ${key}`)).json()) as { + id?: string; + username?: string; + }; + if (!token || !me.id || !me.username) { + throw new Error(`permission-walk seed: ${key} is missing a token, id or username`); + } + return { key, email, username: me.username, userId: me.id, token }; + } finally { + await session.dispose(); + } +} + +async function createItems( + owner: APIRequestContext, + ws: string, + collection: string, + titles: string[] +): Promise { + const items: WalkItem[] = []; + for (const title of titles) { + const resp = await expectOk( + await owner.post(`/api/v1/workspaces/${ws}/collections/${collection}/items`, { + data: { title } + }), + `create ${collection} item` + ); + const item = (await resp.json()) as { slug: string; ref?: string }; + items.push({ slug: item.slug, ref: item.ref ?? '', title }); + } + return items; +} + +/** Seed one walk world. Each call builds a fresh, independent one. */ +export async function seedPermissionWalk(): Promise { + const { baseURL, apiToken } = suiteFixture(); + const tag = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + + const admin = await bearer(baseURL, apiToken); + const accounts = {} as Record; + try { + for (const key of ACCOUNT_KEYS) { + accounts[key] = await mintAccount(baseURL, admin, key, tag); + } + } finally { + await admin.dispose(); + } + + const owner = await bearer(baseURL, accounts.owner.token); + try { + const wsResp = await expectOk( + await owner.post('/api/v1/workspaces', { + data: { name: `Walk ${tag}`, slug: `walk-${tag}`, template: 'startup' } + }), + 'create workspace' + ); + const ws = ((await wsResp.json()) as { slug: string }).slug; + + const tasks = await createItems(owner, ws, 'tasks', ['Walk task A', 'Walk task B', 'Walk task C']); + const ideas = await createItems(owner, ws, 'ideas', ['Walk idea A', 'Walk idea B']); + const grantedTask = tasks[0]; + + const members: [AccountKey, 'editor' | 'viewer'][] = [ + ['editor', 'editor'], + ['viewer', 'viewer'], + ['viewerTasksEdit', 'viewer'], + ['editorSpecific', 'editor'] + ]; + for (const [key, role] of members) { + await expectOk( + await owner.post(`/api/v1/workspaces/${ws}/members/invite`, { + data: { email: accounts[key].email, role } + }), + `invite ${key}` + ); + } + + const tasksColl = (await ( + await expectOk(await owner.get(`/api/v1/workspaces/${ws}/collections/tasks`), 'get tasks') + ).json()) as { id: string }; + await expectOk( + await owner.put( + `/api/v1/workspaces/${ws}/members/${accounts.editorSpecific.userId}/collection-access`, + { data: { mode: 'specific', collection_ids: [tasksColl.id] } } + ), + 'restrict editorSpecific' + ); + + for (const key of ['viewerTasksEdit', 'guestPrecedence'] as const) { + await expectOk( + await owner.post(`/api/v1/workspaces/${ws}/collections/tasks/grants`, { + data: { user_id: accounts[key].userId, permission: 'edit' } + }), + `collection grant ${key}` + ); + } + const itemGrants: [AccountKey, 'edit' | 'view'][] = [ + ['guestItemEdit', 'edit'], + ['guestPrecedence', 'view'] + ]; + for (const [key, permission] of itemGrants) { + await expectOk( + await owner.post(`/api/v1/workspaces/${ws}/items/${grantedTask.slug}/grants`, { + data: { user_id: accounts[key].userId, permission } + }), + `item grant ${key}` + ); + } + + return { + baseURL, + workspaceSlug: ws, + workspacePath: `/${accounts.owner.username}/${ws}`, + accounts, + tasks, + ideas, + grantedTask + }; + } finally { + await owner.dispose(); + } +} + +/** Authenticate a browser context as one walk account. */ +export async function actAs(context: BrowserContext, account: WalkAccount): Promise { + await context.setExtraHTTPHeaders({ Authorization: `Bearer ${account.token}` }); + await quietCrossActorToasts(context); +} diff --git a/web/e2e/permission-walk-sidebar.spec.ts b/web/e2e/permission-walk-sidebar.spec.ts new file mode 100644 index 000000000..57a41dca7 --- /dev/null +++ b/web/e2e/permission-walk-sidebar.spec.ts @@ -0,0 +1,103 @@ +import { test, expect, type Page } from '@playwright/test'; +import { + ACCOUNT_KEYS, + actAs, + seedPermissionWalk, + type AccountKey, + type PermissionWalk +} from './lib/permission-walk'; + +/** + * Permission walk, row 1 of HT-1157: the sidebar lists exactly the + * collections each account may see (TASK-2866 U1, the gate for BUG-984). + * + * The owner leg is the positive control. It proves the workspace rendered + * with Tasks AND Ideas present, so a restricted account missing Ideas is + * missing something that exists, not a page that never loaded. Every + * restricted leg also asserts its own list is non-empty (the Tasks it is + * granted), for the same reason: an absence is only evidence beside a + * presence on the same surface. + * + * This file uses plain @playwright/test, not ./fixtures: that fixture + * authenticates every context as the suite admin, and here each test must be + * exactly one walk account. Desktop only, because the mobile project hides + * the sidebar behind a drawer. Viewport coverage is not what this walk + * measures. + */ + +test.skip(({ isMobile }) => isMobile, 'the sidebar walk runs on the desktop project'); + +let walk: PermissionWalk; + +test.beforeAll(async () => { + walk = await seedPermissionWalk(); +}); + +async function sidebarCollections(page: Page): Promise { + return page.locator('nav.collection-nav .nav-section a.nav-item .nav-label').allInnerTexts(); +} + +async function openWorkspace(page: Page, key: AccountKey) { + await actAs(page.context(), walk.accounts[key]); + await page.goto(walk.workspacePath); + // Every account in the walk sees at least Tasks. Waiting on it is also + // the presence half of each leg's absence assertions. + await expect( + page.locator('nav.collection-nav .nav-section a.nav-item .nav-label', { hasText: /^Tasks$/ }) + ).toBeVisible(); +} + +// What each account's regular Collections section must list, exactly. +// 'ALL' means the owner's own list: every member with collection_access=all +// sees what the owner sees. +const EXPECTED: Record = { + owner: 'ALL', + editor: 'ALL', + viewer: 'ALL', + viewerTasksEdit: 'ALL', + guestItemEdit: ['Tasks'], + guestPrecedence: ['Tasks'], + editorSpecific: ['Tasks'] +}; + +const GUESTS: ReadonlySet = new Set(['guestItemEdit', 'guestPrecedence']); + +let ownerCollections: string[] | undefined; + +async function ownerList(page: Page): Promise { + if (ownerCollections) return ownerCollections; + const ownerPage = await page.context().browser()!.newPage(); + try { + await openWorkspace(ownerPage, 'owner'); + ownerCollections = await sidebarCollections(ownerPage); + } finally { + await ownerPage.context().close(); + } + return ownerCollections; +} + +for (const key of ACCOUNT_KEYS) { + test(`sidebar lists exactly the allowed collections: ${key}`, async ({ page }) => { + await openWorkspace(page, key); + const seen = await sidebarCollections(page); + + const owner = await ownerList(page); + // Positive control: both walk collections exist and render. + expect(owner).toEqual(expect.arrayContaining(['Tasks', 'Ideas'])); + + const expected = EXPECTED[key]; + expect(seen.slice().sort()).toEqual((expected === 'ALL' ? owner : expected).slice().sort()); + }); + + // A guest's list is framed as shared with them, and a member's is not. + // The presence half is the guest legs, the absence half is the members. + test(`sidebar frames the list as shared only for a guest: ${key}`, async ({ page }) => { + // BUG-3254: GET /workspaces/{ws} omits is_guest, and a fresh load + // resolves the workspace through it, so a guest renders as a member. + // Expected to fail until that fix lands; the flip to passing fails + // this test, which is the prompt to delete this line. + test.fail(GUESTS.has(key), 'BUG-3254'); + await openWorkspace(page, key); + await expect(page.getByText('Shared with you', { exact: true })).toHaveCount(GUESTS.has(key) ? 1 : 0); + }); +}