334 lines
15 KiB
TypeScript
334 lines
15 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 { tmpdir } from 'node:os';
|
|
|
|
// ── Local-auth shared-space E2E ───────────────────────────────────────────────
|
|
//
|
|
// Unlike ui/e2e/spaces.spec.ts (auth OFF => synthetic 'local' user, empty member
|
|
// list, /api/users/pickable === []), this harness boots the orchestrator with
|
|
// LOCAL LOGIN ENABLED (ui/playwright.auth.config.ts passes AAO_CONFIG). Real
|
|
// users sign in through the browser, so the member invite + avatar features —
|
|
// which depend on real identities (owner row, pickable users, space_members) —
|
|
// can finally be exercised end-to-end. They are unreachable in the auth-off suite.
|
|
//
|
|
// The bootstrap admin (admin@test.local, id='local', role admin) is auto-seeded
|
|
// at startup. Member users (alice/bob) are seeded into the SAME temp DB in
|
|
// beforeAll, using the BUILT Repository — by the time tests run the webServer is
|
|
// up, so schema + migrations exist.
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const require = createRequire(import.meta.url);
|
|
// __dirname is <repo>/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: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
|
|
// independently — rather than importing the config — keeps the spec decoupled
|
|
// from Playwright's config-resolution internals while guaranteeing both open
|
|
// the SAME SQLite file.
|
|
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
|
|
|
|
const ADMIN = { email: 'admin@test.local', password: 'AdminPass123!' };
|
|
const ALICE = { email: 'alice@test.local', password: 'AlicePass123!', name: 'Alice' };
|
|
const BOB = { email: 'bob@test.local', password: 'BobPass123!', name: 'Bob' };
|
|
|
|
// Space-OWNING admin used to create + manage shared spaces. NOTE: this is a
|
|
// SEPARATE seeded admin with a REAL uuid — NOT the bootstrap admin (whose id is
|
|
// literally 'local'). The space create handler nulls owner_id when the creator's
|
|
// id === 'local' (it treats that as the no-auth synthetic user), so a space
|
|
// created by the bootstrap admin has NO owner row and the avatar stack (members
|
|
// >= 2) can never form. A non-'local' owner is required to exercise the feature.
|
|
const MANAGER = { email: 'manager@test.local', password: 'ManagerPass123!', name: 'Manager' };
|
|
|
|
// Same benign-path tolerance as the auth-off suite, MINUS /api/auth/me (here it
|
|
// is mounted and returns 200). With auth on, the per-task console/browser probes
|
|
// and MCP/SSH/org probes still 401/404 benignly.
|
|
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 space members GET is auth-gated. During the logout→login handoff (test 3),
|
|
// the header avatar query can fire ONE request after the cookie is cleared but
|
|
// before the new session lands → a transient 401. Only a 401 (unauthenticated)
|
|
// is benign here; a 403 (authenticated-but-forbidden) would be a real authz bug
|
|
// and is NOT excused. The avatar assertion still proves the eventual 200.
|
|
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;
|
|
}
|
|
|
|
/** Log a real user in via the browser form. Success → 302 `/` → 302 `/ui` →
|
|
* SPA at `/ui/`. Failure → `/auth/login?error=…`. We wait for the redirect
|
|
* chain to settle off the login page, assert we landed on the SPA (not back on
|
|
* login with an error), then ensure the SPA route is loaded. */
|
|
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, create a case space with a unique title, select it, and return
|
|
* the detail locator + the resolved space id. Mirrors the auth-off helper. */
|
|
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();
|
|
const spaceId = await caseRow.getAttribute('data-space-id');
|
|
expect(spaceId).toBeTruthy();
|
|
await caseRow.click();
|
|
|
|
const detail = page.getByTestId('space-detail');
|
|
await expect(detail).toBeVisible();
|
|
return { detail, caseTitle, rail, spaceId: spaceId as string };
|
|
}
|
|
|
|
/** Navigate the already-open space to 設定 → メンバー and return the panel. */
|
|
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;
|
|
}
|
|
|
|
/** Open the invite picker, pick `name` from the candidate list, and add them
|
|
* (role defaults to 編集者/editor). Asserts the real-user list shows (NOT the
|
|
* no-auth hint) before selecting. */
|
|
async function inviteMember(page: Page, name: string) {
|
|
const panel = page.getByTestId('space-members-panel');
|
|
await panel.getByTestId('space-member-invite').click();
|
|
const picker = panel.getByTestId('space-member-picker');
|
|
await expect(picker).toBeVisible({ timeout: 15_000 });
|
|
// Real active-user pool → list, not the no-auth "認証を有効化すると…" hint.
|
|
await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toHaveCount(0);
|
|
// Candidate rows are buttons named "<initial> <name> <email>"; scope by role
|
|
// + name regex so the name/email substrings don't trip strict mode.
|
|
await picker.getByRole('button', { name: new RegExp(name) }).click();
|
|
await picker.getByRole('button', { name: '追加' }).click();
|
|
}
|
|
|
|
test.beforeAll(() => {
|
|
// The webServer is already up (Playwright waits on its url before running),
|
|
// so the schema + migrations + bootstrap admin all exist. Seed members into
|
|
// the SAME deterministic DB the server opened.
|
|
const dbPath = DB_PATH;
|
|
|
|
// Import the BUILT Repository (named export `class Repository`).
|
|
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
|
|
Repository: new (dbPath: string) => {
|
|
getUserByEmail: (email: string) => unknown;
|
|
createLocalUser: (p: {
|
|
email: string;
|
|
password: string;
|
|
name?: string;
|
|
role: string;
|
|
status: string;
|
|
}) => unknown;
|
|
close?: () => void;
|
|
};
|
|
};
|
|
|
|
const repo = new Repository(dbPath);
|
|
try {
|
|
// Member users (role 'user') + a space-owning admin (role 'admin', real id).
|
|
const seeds = [
|
|
{ ...ALICE, role: 'user' as const },
|
|
{ ...BOB, role: 'user' as const },
|
|
{ ...MANAGER, role: 'admin' as const },
|
|
];
|
|
for (const u of seeds) {
|
|
if (!repo.getUserByEmail(u.email)) {
|
|
repo.createLocalUser({
|
|
email: u.email,
|
|
password: u.password,
|
|
name: u.name,
|
|
role: u.role,
|
|
status: 'active',
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
repo.close?.();
|
|
}
|
|
});
|
|
|
|
// 1. Sanity: auth is actually ON. The bootstrap admin logs in and /api/auth/me
|
|
// reports an active admin. (Under auth-off this endpoint 404s.)
|
|
test('admin login + bootstrap: /api/auth/me reports active admin', async ({ page }) => {
|
|
await login(page, ADMIN.email, ADMIN.password);
|
|
const res = await page.request.get('/api/auth/me');
|
|
expect(res.ok(), `auth/me status ${res.status()}`).toBeTruthy();
|
|
const me = (await res.json()) as { email?: string; role?: string; status?: string };
|
|
expect(me.role).toBe('admin');
|
|
expect(me.email).toBe(ADMIN.email);
|
|
expect(me.status).toBe('active');
|
|
});
|
|
|
|
// 2. THE avatar feature: a (non-'local') owning admin creates a case space,
|
|
// invites alice (editor) via the real picker (NON-empty now that alice is a
|
|
// real active user), and the header avatar stack appears (members >= 2:
|
|
// owner manager + alice).
|
|
test('invite alice → member row + header avatars appear', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
await createAndOpenCaseSpace(page, 'E2E共有');
|
|
|
|
const panel = await openMembersPanel(page);
|
|
|
|
// Before inviting anyone, only the owner (admin) is a member → no avatar stack.
|
|
await expect(page.getByTestId('space-member-avatars')).toHaveCount(0);
|
|
|
|
// Invite alice via the real picker (non-empty now that she is a real active
|
|
// user; also asserts the no-auth hint is absent).
|
|
await inviteMember(page, ALICE.name);
|
|
|
|
// Alice now appears as a member row in the panel.
|
|
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
|
|
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
// AND the header avatar stack renders now that the space has >= 2 members.
|
|
// We reload first: the header avatar query keys on ['spaceMembers'] while the
|
|
// panel invalidates ['space-members'] — different keys, so the header does NOT
|
|
// live-refetch after an in-session invite (a real, minor product cache-key
|
|
// mismatch — see the harness report). A reload is what a user sees on the next
|
|
// navigation; this asserts the actual rendered avatar feature, not a cache race.
|
|
await page.reload();
|
|
await expect(page.getByTestId('space-detail')).toBeVisible();
|
|
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
// 3. Member collaboration: alice logs in, sees the shared space in her rail
|
|
// (membership grants visibility), opens it, and the avatar stack renders for
|
|
// her too. She is NOT admin and not the owner, so she gets read-only member UI.
|
|
test('alice sees + opens the shared space, avatars render', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
// The owning admin creates a fresh shared space and invites alice.
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
const { spaceId } = await createAndOpenCaseSpace(page, 'E2E協働');
|
|
const panel = await openMembersPanel(page);
|
|
await inviteMember(page, ALICE.name);
|
|
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
|
|
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Switch to alice.
|
|
await logout(page);
|
|
await login(page, ALICE.email, ALICE.password);
|
|
|
|
// The shared space is visible in alice's rail (space_members membership).
|
|
await page.getByTestId('nav-spaces').click();
|
|
const rail = page.getByTestId('space-rail');
|
|
await expect(rail).toBeVisible();
|
|
const sharedRow = rail.locator(`[data-testid="space-row"][data-space-id="${spaceId}"]`);
|
|
await expect(sharedRow).toBeVisible({ timeout: 15_000 });
|
|
|
|
// She can open it (membership grants access — no 403).
|
|
await sharedRow.click();
|
|
await expect(page.getByTestId('space-detail')).toBeVisible();
|
|
|
|
// The avatar stack renders for her as well (>= 2 members).
|
|
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|
|
|
|
// 4. Role + remove: the owning admin changes alice's role to viewer, then
|
|
// removes her. The member row disappears and the avatar stack collapses back
|
|
// (owner only => members < 2).
|
|
test('admin changes alice role then removes her', async ({ page }) => {
|
|
const fatalErrors = trackFatalErrors(page);
|
|
|
|
await login(page, MANAGER.email, MANAGER.password);
|
|
await createAndOpenCaseSpace(page, 'E2E権限');
|
|
let panel = await openMembersPanel(page);
|
|
await inviteMember(page, ALICE.name);
|
|
|
|
// Resolve alice's member row + her userId from the row testid suffix
|
|
// (`space-member-{userId}`; the row wrapper is the first prefix match).
|
|
const aliceRow = () =>
|
|
page.getByTestId('space-members-panel')
|
|
.locator('div[data-testid^="space-member-"]')
|
|
.filter({ hasText: ALICE.name })
|
|
.first();
|
|
await expect(aliceRow()).toBeVisible({ timeout: 15_000 });
|
|
const testid = await aliceRow().getAttribute('data-testid');
|
|
const aliceId = (testid ?? '').replace(/^space-member-/, '');
|
|
expect(aliceId).toBeTruthy();
|
|
|
|
// Avatars present while she is a member (reload so the header's ['spaceMembers']
|
|
// query picks up the invite — see the cache-key note in test 2 / the report).
|
|
await page.reload();
|
|
await expect(page.getByTestId('space-detail')).toBeVisible();
|
|
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Re-open the panel (reload reset the tab) and change alice editor → viewer.
|
|
panel = await openMembersPanel(page);
|
|
const roleSelect = panel.getByTestId(`space-member-role-${aliceId}`);
|
|
await roleSelect.selectOption('viewer');
|
|
await expect(roleSelect).toHaveValue('viewer');
|
|
|
|
// Remove her (auto-accept the confirm dialog). The panel's own query
|
|
// invalidates on the same key, so her row disappears in-session.
|
|
page.once('dialog', (d) => d.accept());
|
|
await panel.getByTestId(`space-member-remove-${aliceId}`).click();
|
|
await expect(panel.getByTestId(`space-member-${aliceId}`)).toHaveCount(0, { timeout: 15_000 });
|
|
|
|
// After a reload the header avatar stack collapses (owner only => < 2 members).
|
|
await page.reload();
|
|
await expect(page.getByTestId('space-detail')).toBeVisible();
|
|
await expect(page.getByTestId('space-member-avatars')).toHaveCount(0, { timeout: 15_000 });
|
|
|
|
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
|
});
|