mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
test(e2e): seed a multi-account permission walk and check each account's sidebar (TASK-2866 U1) (#1613)
HT-1157 is BUG-984's quality gate, and it had never run. This adds the
fixture its walk needs: a workspace of its own, plus one account per
permission shape (owner, editor, viewer, viewer with Tasks collection-edit,
guest with one item-edit, guest with collection-edit plus item-view as the
precedence case, and editor restricted to Tasks). Each account holds its
own API token. The first checklist row is also here: the sidebar lists
exactly the collections each account may see.
The owner leg is the positive control. Every restricted leg also requires
its own Tasks entry, so an absence is measured beside a presence on the
same surface. Negative control: seeding the restricted editor with
collection_access=all turns its leg red with 3 extra collections.
The walk found one leak. GET /workspaces/{ws} omits is_guest, so a guest
loading the workspace renders member chrome (BUG-3254). That leg is marked
test.fail with the ref until the fix lands.
Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
@@ -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<AccountKey, WalkAccount>;
|
||||
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<ReturnType<APIRequestContext['get']>>, 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<WalkAccount> {
|
||||
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<WalkItem[]> {
|
||||
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<PermissionWalk> {
|
||||
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<AccountKey, WalkAccount>;
|
||||
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<void> {
|
||||
await context.setExtraHTTPHeaders({ Authorization: `Bearer ${account.token}` });
|
||||
await quietCrossActorToasts(context);
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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<AccountKey, 'ALL' | string[]> = {
|
||||
owner: 'ALL',
|
||||
editor: 'ALL',
|
||||
viewer: 'ALL',
|
||||
viewerTasksEdit: 'ALL',
|
||||
guestItemEdit: ['Tasks'],
|
||||
guestPrecedence: ['Tasks'],
|
||||
editorSpecific: ['Tasks']
|
||||
};
|
||||
|
||||
const GUESTS: ReadonlySet<AccountKey> = new Set(['guestItemEdit', 'guestPrecedence']);
|
||||
|
||||
let ownerCollections: string[] | undefined;
|
||||
|
||||
async function ownerList(page: Page): Promise<string[]> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user