360 lines
17 KiB
TypeScript
360 lines
17 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve, join } from 'node:path';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
// ── Local-auth SHARING-SCOPE E2E ──────────────────────────────────────────────
|
|
//
|
|
// Proves the access-model fix on feat/space-visibility-and-org-picker is scoped
|
|
// correctly across THREE personas, end-to-end through the real server with LOCAL
|
|
// AUTH on (ui/playwright.auth.config.ts → ui/e2e-auth/config.e2e-auth.yaml):
|
|
//
|
|
// manager — owns the case space, manages members. role 'user' (NON-admin):
|
|
// admins see every user in the picker, which would defeat the
|
|
// org-scope assertion. A regular user only sees same-org members.
|
|
// member — invited (editor). Shares an org with manager → appears in picker
|
|
// and (via space_members) can read the manager-owned chat.
|
|
// stranger — never invited, in NO org → must NOT see the space, its chat, or
|
|
// appear in the picker.
|
|
//
|
|
// IDENTITY ISOLATION: this file uses its own *@scope.local emails so it never
|
|
// collides with shared-space.auth.spec.ts (which seeds manager@test.local as an
|
|
// ADMIN into the SAME shared DB). Reusing that admin would silently make the
|
|
// picker non-org-scoped.
|
|
//
|
|
// ORG SEEDING (real, not fallback): orgIds are computed at login (auth.ts
|
|
// deserializeUser → resolveOrgIds, union of Gitea cache + local_org_members), so
|
|
// seeding a LOCAL org and adding manager+member to it BEFORE they log in makes
|
|
// both carry that orgId in their session — exactly what /api/users/pickable
|
|
// (repo.listActiveUsersInOrgs) filters on. stranger is left out, so the picker
|
|
// excludes them and the invite genuinely works.
|
|
//
|
|
// CHAT SEEDING: the shared space + a chat task owned by manager are created in
|
|
// beforeAll via the BUILT Repository, with a real workspace dir (+ logs/), so the
|
|
// files/logs endpoints return 200 for members and 404 for the stranger. Seeding
|
|
// in beforeAll (not a test body) keeps all ids deterministic for every test —
|
|
// Playwright does not guarantee module-level mutations from one test reach the
|
|
// next.
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const require = createRequire(import.meta.url);
|
|
const repoRoot = resolve(__dirname, '..', '..');
|
|
|
|
// Same deterministic DB + worktree dir the webServer opens (playwright.auth.config.ts).
|
|
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
|
|
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
|
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
|
|
|
|
const MANAGER = { email: 'mgr@scope.local', password: 'MgrPass123!', name: 'ScopeManager' };
|
|
const MEMBER = { email: 'mbr@scope.local', password: 'MbrPass123!', name: 'ScopeMember' };
|
|
const STRANGER = { email: 'str@scope.local', password: 'StrPass123!', name: 'ScopeStranger' };
|
|
|
|
// Resolved in beforeAll, reused by every test.
|
|
let managerId = '';
|
|
let memberId = '';
|
|
let strangerId = '';
|
|
let sharedSpaceId = '';
|
|
let chatTaskId = 0;
|
|
|
|
const BENIGN_PATHS = new Set([
|
|
'/api/users/me/orgs',
|
|
'/api/mcp/connections',
|
|
'/api/mcp/servers',
|
|
'/api/mcp/user-servers',
|
|
'/api/ssh/connections',
|
|
]);
|
|
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
|
const BENIGN_PATH_RE =
|
|
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
|
|
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
|
|
|
|
function trackFatalErrors(page: Page): string[] {
|
|
const fatalErrors: string[] = [];
|
|
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
|
page.on('response', (res) => {
|
|
if (res.status() >= 400) {
|
|
const { pathname } = new URL(res.url());
|
|
const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname);
|
|
if (
|
|
!BENIGN_ASSET.test(pathname) &&
|
|
!BENIGN_PATHS.has(pathname) &&
|
|
!BENIGN_PATH_RE.test(pathname) &&
|
|
!transientMembers401
|
|
) {
|
|
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
|
}
|
|
}
|
|
});
|
|
return fatalErrors;
|
|
}
|
|
|
|
async function login(page: Page, email: string, password: string) {
|
|
await page.goto('/auth/login');
|
|
await page.fill('input[name="email"]', email);
|
|
await page.fill('input[name="password"]', password);
|
|
await Promise.all([
|
|
page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }),
|
|
page.click('button[type="submit"]'),
|
|
]);
|
|
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
|
|
}
|
|
|
|
async function logout(page: Page) {
|
|
await page.goto('/auth/logout');
|
|
}
|
|
|
|
/** Open Spaces and select the seeded shared space; returns the detail locator. */
|
|
async function openSharedSpace(page: Page) {
|
|
await page.goto('/ui');
|
|
await page.getByTestId('nav-spaces').click();
|
|
const rail = page.getByTestId('space-rail');
|
|
await expect(rail).toBeVisible();
|
|
const row = rail.locator(`[data-testid="space-row"][data-space-id="${sharedSpaceId}"]`);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await row.click();
|
|
const detail = page.getByTestId('space-detail');
|
|
await expect(detail).toBeVisible();
|
|
return detail;
|
|
}
|
|
|
|
/** Open the seeded chat in the already-open shared space; returns the conversation. */
|
|
async function openSeededChat(page: Page) {
|
|
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${chatTaskId}"]`);
|
|
await expect(chatRow).toBeVisible({ timeout: 15_000 });
|
|
await chatRow.click();
|
|
const conv = page.getByTestId('space-conversation');
|
|
await expect(conv).toBeVisible();
|
|
return conv;
|
|
}
|
|
|
|
/** Create a fresh case space via the UI and select it (for the picker test). */
|
|
async function createAndOpenCaseSpace(page: Page, label: string) {
|
|
await page.goto('/ui');
|
|
await page.getByTestId('nav-spaces').click();
|
|
const rail = page.getByTestId('space-rail');
|
|
await expect(rail).toBeVisible();
|
|
await page.getByTestId('create-space-btn').click();
|
|
const titleInput = page.getByTestId('space-title-input');
|
|
await expect(titleInput).toBeVisible();
|
|
const caseTitle = `${label}-${Date.now()}`;
|
|
await titleInput.fill(caseTitle);
|
|
await page.getByTestId('create-space-submit').click();
|
|
await expect(titleInput).toHaveCount(0);
|
|
const caseRow = rail
|
|
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
|
.filter({ hasText: caseTitle });
|
|
await expect(caseRow).toBeVisible();
|
|
await caseRow.click();
|
|
await expect(page.getByTestId('space-detail')).toBeVisible();
|
|
}
|
|
|
|
async function openMembersPanel(page: Page) {
|
|
await page.getByTestId('space-tab-settings').click();
|
|
await page.getByTestId('space-settings-nav-members').click();
|
|
const panel = page.getByTestId('space-members-panel');
|
|
await expect(panel).toBeVisible({ timeout: 15_000 });
|
|
return panel;
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
// webServer is already up: schema + migrations + bootstrap admin exist. Seed
|
|
// the three personas, a local org (manager + member, NOT stranger), the shared
|
|
// case space (manager owner, member editor), and a chat task owned by manager
|
|
// with a real workspace + logs/ dir — all into the SAME deterministic DB.
|
|
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
|
|
Repository: new (dbPath: string) => {
|
|
getUserByEmail: (email: string) => { id: string } | null;
|
|
createLocalUser: (p: {
|
|
email: string; password: string; name?: string; role: string; status: string;
|
|
}) => { id: string };
|
|
createLocalOrg: (name: string, createdBy: string | null) => { id: string };
|
|
addOrgMember: (orgId: string, userId: string, role?: string) => void;
|
|
listLocalOrgs: () => Array<{ id: string; name: string }>;
|
|
listOrgMembers: (orgId: string) => Array<{ userId: string; role: string }>;
|
|
createSpace: (p: {
|
|
kind: string; title: string; ownerId: string; visibility?: string;
|
|
}) => Promise<{ id: string }>;
|
|
addSpaceMember: (p: {
|
|
spaceId: string; userId: string; role: string; invitedBy?: string | null;
|
|
}) => Promise<void>;
|
|
createLocalTask: (p: Record<string, unknown>) => Promise<{ id: number }>;
|
|
close?: () => void;
|
|
};
|
|
};
|
|
|
|
const repo = new Repository(DB_PATH);
|
|
try {
|
|
const ensure = (u: typeof MANAGER, role: string) => {
|
|
const existing = repo.getUserByEmail(u.email);
|
|
if (existing) return existing.id;
|
|
return repo.createLocalUser({
|
|
email: u.email, password: u.password, name: u.name, role, status: 'active',
|
|
}).id;
|
|
};
|
|
// manager is a REGULAR user (admins bypass org-scoping in the picker).
|
|
managerId = ensure(MANAGER, 'user');
|
|
memberId = ensure(MEMBER, 'user');
|
|
strangerId = ensure(STRANGER, 'user');
|
|
|
|
// REAL local-org seeding (idempotent across reruns of the throwaway DB).
|
|
const ORG_NAME = 'e2e-sharing-scope-orgA';
|
|
const existingOrg = repo.listLocalOrgs().find((o) => o.name === ORG_NAME);
|
|
const orgId = existingOrg ? existingOrg.id : repo.createLocalOrg(ORG_NAME, managerId).id;
|
|
repo.addOrgMember(orgId, managerId, 'member');
|
|
repo.addOrgMember(orgId, memberId, 'member');
|
|
// stranger intentionally NOT added to the org.
|
|
|
|
const orgMemberIds = repo.listOrgMembers(orgId).map((m) => m.userId);
|
|
if (!orgMemberIds.includes(managerId) || !orgMemberIds.includes(memberId) || orgMemberIds.includes(strangerId)) {
|
|
throw new Error(`org seeding wrong: ${JSON.stringify(orgMemberIds)}`);
|
|
}
|
|
|
|
// Shared case space: manager owner, member editor, stranger absent.
|
|
const space = await repo.createSpace({ kind: 'case', title: '案件-共有範囲', ownerId: managerId, visibility: 'private' });
|
|
sharedSpaceId = space.id;
|
|
await repo.addSpaceMember({ spaceId: sharedSpaceId, userId: memberId, role: 'editor', invitedBy: managerId });
|
|
|
|
// Chat task owned by manager, inside the space, forced private, with a real
|
|
// workspace + logs/ dir (so files?section=logs resolves, not "Workspace not
|
|
// found"). Mirrors src/bridge/local-tasks-api.space-view.test.ts.
|
|
const ws = join(WORKTREE_DIR, 'space', sharedSpaceId, 'scope-chat-ws');
|
|
mkdirSync(join(ws, 'logs'), { recursive: true });
|
|
mkdirSync(join(ws, 'input'), { recursive: true });
|
|
mkdirSync(join(ws, 'output'), { recursive: true });
|
|
writeFileSync(join(ws, 'logs', 'activity.log'), 'seeded log line\n');
|
|
const task = await repo.createLocalTask({
|
|
title: '共有チャットの本文タイトル',
|
|
body: '共有スペースの会話本文です。',
|
|
ownerId: managerId,
|
|
visibility: 'private',
|
|
spaceId: sharedSpaceId,
|
|
workspacePath: ws,
|
|
});
|
|
chatTaskId = task.id;
|
|
} finally {
|
|
repo.close?.();
|
|
}
|
|
});
|
|
|
|
// Sanity: the seed produced usable ids and the chat is owner-readable (baseline
|
|
// 200 for the file-scope assertions). Logs in as manager and reads back.
|
|
test('seed sanity: manager can read own space chat detail + logs', async ({ page }) => {
|
|
expect(sharedSpaceId, 'shared space seeded').toBeTruthy();
|
|
expect(chatTaskId, 'chat task seeded').toBeGreaterThan(0);
|
|
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
|
|
expect(detail.status(), 'manager reads own chat').toBe(200);
|
|
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
|
|
expect(logs.status(), 'manager reads own chat logs').toBe(200);
|
|
});
|
|
|
|
// 1. MEMBER can collaborate (UI + API). The shared space appears in member's
|
|
// rail; member opens the manager-owned chat; the chat detail + files/logs
|
|
// load. API-level: GET task === 200 and files?section=logs === 200 for member.
|
|
test('member sees the shared space, opens manager chat, files/logs load (200)', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, MEMBER.email, MEMBER.password);
|
|
await openSharedSpace(page); // space visible + opens (membership grants access, no 403)
|
|
|
|
// The manager-owned chat row is listed; open it; its content (manager's title)
|
|
// is visible to the member.
|
|
const conv = await openSeededChat(page);
|
|
await expect(conv).toContainText('共有チャット', { timeout: 15_000 });
|
|
|
|
// Switch to this chat's Files tab → it becomes the selected tab and the
|
|
// tabpanel (id, not testid) renders (no 404/error). The per-chat Files tab is
|
|
// distinct from the space-level ファイル tab; it shows runs/{taskId} logs.
|
|
const filesTab = page.getByTestId('space-chat-tab-files');
|
|
await expect(filesTab).toBeVisible({ timeout: 15_000 });
|
|
await filesTab.click();
|
|
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
|
|
await expect(page.locator('#space-chat-tabpanel')).toBeVisible();
|
|
|
|
// API-level scope proof in member's authed browser context.
|
|
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
|
|
expect(detail.status(), 'member reads co-member chat detail').toBe(200);
|
|
const detailBody = (await detail.json()) as { task?: { title?: string } };
|
|
expect(detailBody.task?.title).toContain('共有チャット');
|
|
|
|
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
|
|
expect(logs.status(), 'member reads co-member chat logs').toBe(200);
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
// 2. STRANGER is blocked. The space is NOT in stranger's rail. API-level:
|
|
// GET task === 404 (+ files === 404, + space === 404). The 404s are driven via
|
|
// page.request (NOT a navigation), so the fatal tracker never sees the
|
|
// expected 404 — it stays scoped to DOM navigation requests.
|
|
test('stranger cannot see the shared space or its chat (404)', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, STRANGER.email, STRANGER.password);
|
|
await page.goto('/ui');
|
|
await page.getByTestId('nav-spaces').click();
|
|
const rail = page.getByTestId('space-rail');
|
|
await expect(rail).toBeVisible();
|
|
|
|
// The shared space is NOT in stranger's rail.
|
|
const sharedRow = rail.locator(`[data-testid="space-row"][data-space-id="${sharedSpaceId}"]`);
|
|
await expect(sharedRow).toHaveCount(0);
|
|
|
|
// API-level scope proof: stranger is denied the chat detail + logs (404), the
|
|
// SAME requests the member got 200 on. Out-of-band fetches with stranger's
|
|
// cookies → the navigation-scoped fatal tracker does not observe them.
|
|
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
|
|
expect(detail.status(), 'stranger denied chat detail').toBe(404);
|
|
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
|
|
expect(logs.status(), 'stranger denied chat logs').toBe(404);
|
|
const space = await page.request.get(`/api/local/spaces/${sharedSpaceId}`);
|
|
expect(space.status(), 'stranger denied the space').toBe(404);
|
|
|
|
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
// 3. PICKER org-scope. In manager's invite picker, `member` (same org) is listed
|
|
// and `stranger` (no shared org) is NOT. Also asserts the org-scope note.
|
|
test('invite picker lists same-org member, excludes the stranger', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
// Fresh space so no one is pre-added (clean candidate set).
|
|
await createAndOpenCaseSpace(page, 'E2E組織スコープ');
|
|
|
|
const panel = await openMembersPanel(page);
|
|
await panel.getByTestId('space-member-invite').click();
|
|
const picker = panel.getByTestId('space-member-picker');
|
|
await expect(picker).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Org-scope note shown (replaces the no-auth hint).
|
|
await expect(picker.getByTestId('space-member-picker-org-note')).toBeVisible();
|
|
await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toHaveCount(0);
|
|
|
|
// Same-org member is a candidate; the stranger (no shared org) is NOT.
|
|
await expect(picker.getByRole('button', { name: new RegExp(MEMBER.name) })).toBeVisible({ timeout: 15_000 });
|
|
await expect(picker.getByRole('button', { name: new RegExp(STRANGER.name) })).toHaveCount(0);
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
// 4. NO visibility selector. The space chat shows the static visibility NOTE and
|
|
// never the removed selector control.
|
|
test('space chat shows the static visibility note, not a selector', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
await openSharedSpace(page);
|
|
await openSeededChat(page);
|
|
|
|
// The static note is present; the removed selector is absent.
|
|
await expect(page.getByTestId('space-chat-visibility-note')).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByTestId('space-chat-visibility-note')).toContainText('メンバーに公開');
|
|
await expect(page.getByTestId('space-chat-visibility')).toHaveCount(0);
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|