282 lines
13 KiB
TypeScript
282 lines
13 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 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: '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<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([]);
|
|
});
|