sync: update from private repo (dfadcd5f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+53
View File
@@ -0,0 +1,53 @@
# E2E LOCAL-AUTH harness config.
#
# Loaded via AAO_CONFIG by ui/playwright.auth.config.ts. Unlike the auth-off
# harness (ui/playwright.config.ts, no AAO_CONFIG => synthetic 'local' user),
# this boots the orchestrator with LOCAL LOGIN ENABLED so real users sign in
# through the browser. This is the ONLY way to exercise the shared-space member
# invite + avatar features end-to-end: they depend on real user identities
# (owner row, /api/users/pickable, space_members membership) that simply do not
# exist under no-auth.
#
# - DB_PATH / WORKTREE_DIR come from env (the playwright config mks fresh temp
# dirs and passes them to BOTH the webServer and the seeding code), so no
# storage paths are pinned here.
# - session_secret is inlined (fixed throwaway value) so the server never
# generates a key file under data/ — keeps the repo clean and the run
# deterministic.
# - secure_cookie: false is REQUIRED — over plain http://127.0.0.1 a secure
# cookie is never sent back, so login would appear to fail.
auth:
session_secret: "e2e-fixed-secret-do-not-use-in-prod-0123456789"
secure_cookie: false
primary_provider: local
local:
enabled: true
allow_signup: false
bootstrap_admin:
email: "[email protected]"
password: "AdminPass123!"
# A single ENABLED worker pointed at an UNREACHABLE endpoint. It must be enabled
# with a non-empty endpoint + model + execution role so the browser setup
# wizard's needsSetup gate (scripts/setup-lib.mjs isLlmConfigured, evaluated over
# the resolved provider.workers) is satisfied — otherwise the SPA renders the
# first-run wizard instead of the real app and no spaces UI is reachable.
#
# NOTE: this MUST be the legacy `provider.workers` block (the runtime-effective
# array that worker.ts and computeNeedsSetup read). The v2 `llm.workers` block is
# NOT mapped back into provider.workers at load time — a config that only sets
# `llm.workers` falls back to the synthetic model-less `default` worker and
# needsSetup stays true.
#
# Reachability is irrelevant: the member/avatar tests never run a job, so the
# queued job that would need this LLM is never picked up.
provider:
base_url: http://127.0.0.1:1/v1
model: e2e-noop
workers:
- id: e2e-noop
endpoint: http://127.0.0.1:1/v1
model: e2e-noop
roles: [auto, fast, quality]
enabled: true
+333
View File
@@ -0,0 +1,333 @@
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 ([email protected], 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: '[email protected]', password: 'AdminPass123!' };
const ALICE = { email: '[email protected]', password: 'AlicePass123!', name: 'Alice' };
const BOB = { email: '[email protected]', 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: '[email protected]', 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('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();
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([]);
});
+359
View File
@@ -0,0 +1,359 @@
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 [email protected] 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: '[email protected]', password: 'MgrPass123!', name: 'ScopeManager' };
const MEMBER = { email: '[email protected]', password: 'MbrPass123!', name: 'ScopeMember' };
const STRANGER = { email: '[email protected]', 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([]);
});
+281
View File
@@ -0,0 +1,281 @@
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 <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 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: '[email protected]', password: 'OwnerPass123!', name: 'InviteOwner' };
const JOINER = { email: '[email protected]', 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<string> {
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/<token> 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=<id>.
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/<token> 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([]);
});