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 { tmpdir } from 'node:os'; // ── Local-auth WORKSPACE-INVITE-LINK E2E ────────────────────────────────────── // // Proves the space invite-link feature on feat/space-invite-links, end-to-end // through the real server with LOCAL AUTH on (ui/playwright.auth.config.ts → // ui/e2e-auth/config.e2e-auth.yaml). The WHOLE POINT of an invite link is that it // bypasses the org-scoped picker: a user who shares NO org with the owner can // still join via the token. So two personas suffice: // // owner — owns a case space, can manage it (mint/revoke invites). role 'user'. // joiner — a separate active user in NO shared org with owner. Must NOT see the // space until they accept the invite, then becomes a member. // // IDENTITY ISOLATION: this file uses its own *@invite.local emails so it never // collides with shared-space.auth.spec.ts / sharing-scope.auth.spec.ts, which // seed personas into the SAME shared DB. Reusing those (esp. an admin or an // org-sharing pair) would mask the "no shared org" precondition the invite relies // on. // // SEEDING (real, not fallback): both users + the owner's case space are created in // beforeAll via the BUILT Repository (compiled dist), into the SAME deterministic // DB the webServer opened. No org membership is granted to EITHER user, so the // joiner is provably outside the owner's org-scoped candidate pool. Seeding in // beforeAll (not a test body) keeps the ids deterministic for every test. // // DRIVING STYLE: invite CREATION / REVOCATION runs via authenticated page.request // from the OWNER's browser context (the API is the contract); the JOIN flow is // exercised through the REAL UI at /ui/invite/:token (JoinSpace.tsx). const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); // __dirname is /ui/e2e-auth → repo root is two levels up. const repoRoot = resolve(__dirname, '..', '..'); // Same deterministic DB path the webServer uses (ui/playwright.auth.config.ts // builds it identically: /maestro-e2e-auth/e2e-auth.db). Computing it // independently keeps the spec decoupled from Playwright's config internals while // guaranteeing both open the SAME SQLite file. const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db'); const OWNER = { email: 'owner@invite.local', password: 'OwnerPass123!', name: 'InviteOwner' }; const JOINER = { email: 'joiner@invite.local', password: 'JoinerPass123!', name: 'InviteJoiner' }; // Resolved in beforeAll, reused by every test. let ownerId = ''; let joinerId = ''; let caseSpaceId = ''; 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+$/; // The invite preview GET is intentionally 404 for invalid/revoked tokens (tests 3 // & 4 navigate to such a token through the UI → JoinSpace fetches the preview and // the server returns 404 by design). That 404 is the feature working, not a bug, // so it must not trip the navigation-scoped fatal tracker. const INVITE_PREVIEW_RE = /\/api\/local\/spaces\/invite\/[^/]+$/; 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 expectedInvitePreview404 = res.status() === 404 && INVITE_PREVIEW_RE.test(pathname); if ( !BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname) && !BENIGN_PATH_RE.test(pathname) && !expectedInvitePreview404 ) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); return fatalErrors; } /** Log a real user in via the browser form. Success → redirect chain settles off * /auth/login onto the SPA at /ui/. */ 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'); } /** Mint an invite for the seeded case space via the owner's authed page.request. * Returns the captured token. */ async function createInvite(page: Page, role: 'viewer' | 'editor'): Promise { const res = await page.request.post(`/api/local/spaces/${caseSpaceId}/invite`, { data: { role }, }); expect(res.status(), `owner mints invite (role ${role})`).toBe(201); const body = (await res.json()) as { invite?: { token?: string; role?: string; valid?: boolean; url?: string }; }; expect(body.invite?.role, 'minted invite has the requested role').toBe(role); expect(body.invite?.valid, 'minted invite is valid').toBe(true); const token = body.invite?.token; expect(token, 'invite token captured').toBeTruthy(); return token as string; } test.beforeAll(async () => { // The webServer is already up (Playwright waits on its url), so schema + // migrations + bootstrap admin all exist. Seed the two personas (both regular // 'user's, NEITHER in any org → no shared org) and the owner-owned case space // 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 }; createSpace: (p: { kind: string; title: string; ownerId: string; visibility?: string; }) => Promise<{ id: string }>; close?: () => void; }; }; const repo = new Repository(DB_PATH); try { const ensure = (u: typeof OWNER, 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; }; // Both are REGULAR users; neither is added to any org → the invite link is the // only path by which the joiner can reach the owner's private space. ownerId = ensure(OWNER, 'user'); joinerId = ensure(JOINER, 'user'); // Owner-owned private case space. No members pre-added → the joiner starts // strictly outside. const space = await repo.createSpace({ kind: 'case', title: '案件-招待リンク', ownerId, visibility: 'private', }); caseSpaceId = space.id; } finally { repo.close?.(); } }); // 0. Sanity: the seed produced usable ids and the joiner is genuinely an outsider // (cannot see the space, is not a member) — the precondition for every test. test('seed sanity: joiner is an outsider (no space visibility, not a member)', async ({ page }) => { expect(caseSpaceId, 'case space seeded').toBeTruthy(); expect(joinerId, 'joiner seeded').toBeTruthy(); await login(page, JOINER.email, JOINER.password); // The owner's private space is NOT in the joiner's spaces list. const list = await page.request.get('/api/local/spaces'); expect(list.status(), 'joiner lists spaces').toBe(200); const spaces = (await list.json()) as Array<{ id: string }>; expect(spaces.some((s) => s.id === caseSpaceId), 'joiner cannot see the space pre-join').toBe(false); // And the space itself is denied (404 — not enumerable). const space = await page.request.get(`/api/local/spaces/${caseSpaceId}`); expect(space.status(), 'joiner denied the space pre-join').toBe(404); }); // 1+2. Owner mints a viewer invite (via page.request); the joiner (NOT in owner's // org) opens /ui/invite/ in the REAL UI, sees the JoinSpace screen // with the space title + accept button, joins, lands on the spaces page, and // is now a member. This is the core "invite bypasses org-scope" proof. test('joiner opens the invite link and joins the space (org-scope bypassed)', async ({ page }) => { const fatalErrors = trackFatalErrors(page); // Owner mints the invite. await login(page, OWNER.email, OWNER.password); const token = await createInvite(page, 'viewer'); // Switch to the joiner and walk the real invite flow. await logout(page); await login(page, JOINER.email, JOINER.password); await page.goto(`/ui/invite/${token}`); const joinScreen = page.getByTestId('join-space'); await expect(joinScreen).toBeVisible({ timeout: 15_000 }); // Preview shows the space title + an accept button (the 'ok' branch). await expect(joinScreen).toContainText('案件-招待リンク', { timeout: 15_000 }); const accept = page.getByTestId('join-space-accept'); await expect(accept).toBeVisible(); // Accept → JoinSpace navigates to /ui/?page=spaces&space=. await Promise.all([ page.waitForURL(/\/ui\/\?page=spaces&space=/, { timeout: 15_000 }), accept.click(), ]); expect(page.url()).toContain(`space=${caseSpaceId}`); // The joiner is now a member: the members list (in their own authed context) // includes their userId. const members = await page.request.get(`/api/local/spaces/${caseSpaceId}/members`); expect(members.status(), 'joiner reads members after joining').toBe(200); const roster = (await members.json()) as Array<{ userId: string; role: string }>; expect(roster.some((m) => m.userId === joinerId), 'joiner appears in the roster').toBe(true); // And the space is now visible in their spaces list. const list = await page.request.get('/api/local/spaces'); const spaces = (await list.json()) as Array<{ id: string }>; expect(spaces.some((s) => s.id === caseSpaceId), 'joiner now sees the space').toBe(true); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 3. An invalid / unknown token shows the invalid state — NOT the accept button. test('invalid token shows the invalid state, no accept button', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await login(page, JOINER.email, JOINER.password); await page.goto('/ui/invite/does-not-exist'); // The JoinSpace shell still renders, but in the invalid branch: invalid copy is // shown and there is no accept button. const joinScreen = page.getByTestId('join-space'); await expect(joinScreen).toBeVisible({ timeout: 15_000 }); await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 }); await expect(page.getByTestId('join-space-accept')).toHaveCount(0); // The expected preview 404 is excused by the tracker; nothing else should error. expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 4. After the owner REVOKES the invite (page.request DELETE), the SAME token no // longer previews/accepts: navigating to /ui/invite/ shows the invalid // state. Uses a fresh invite so revocation here can't affect other tests. test('revoked token can no longer be used to join', async ({ page }) => { const fatalErrors = trackFatalErrors(page); // Owner mints a fresh invite, then immediately revokes it. await login(page, OWNER.email, OWNER.password); const token = await createInvite(page, 'viewer'); // Preview is valid while live (owner context, page.request — out of band). const before = await page.request.get(`/api/local/spaces/invite/${token}`); expect(before.status(), 'invite previews while live').toBe(200); const del = await page.request.delete(`/api/local/spaces/${caseSpaceId}/invite`); expect(del.status(), 'owner revokes the invite').toBe(204); // Switch to the joiner: the revoked token now renders the invalid state. await logout(page); await login(page, JOINER.email, JOINER.password); await page.goto(`/ui/invite/${token}`); const joinScreen = page.getByTestId('join-space'); await expect(joinScreen).toBeVisible({ timeout: 15_000 }); await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 }); await expect(page.getByTestId('join-space-accept')).toHaveCount(0); // The preview API itself is 404 for the revoked token (proof at the contract). const after = await page.request.get(`/api/local/spaces/invite/${token}`); expect(after.status(), 'revoked token previews as 404').toBe(404); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); });