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. * * The chat list splits by owner scope ('自分' / '他のメンバー'). The seeded chat is * owned by `manager`, so a viewing MEMBER finds it under '他のメンバー' (the default * '自分' scope hides it). The toggle only renders when others' chats exist * (`hasOthersTasks`), so for the OWNER (manager) it is absent and the chat is already * visible under '自分'. Click the others-scope tab only when it is present. */ async function openSeededChat(page: Page) { const othersScope = page.getByTestId('space-chat-scope-others'); if (await othersScope.count()) { await othersScope.click(); } 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('space-form-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; createLocalTask: (p: Record) => 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([]); });