diff --git a/.gitignore b/.gitignore index 61b00a225cf..b635827af75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ +node_modules node_modules/ +**/node_modules +**/node_modules/ dist/ .env *.tsbuildinfo diff --git a/tests/e2e/multi-user.spec.ts b/tests/e2e/multi-user.spec.ts new file mode 100644 index 00000000000..d03eac35536 --- /dev/null +++ b/tests/e2e/multi-user.spec.ts @@ -0,0 +1,518 @@ +import { test, expect, type Page, type APIRequestContext } from "@playwright/test"; + +/** + * E2E: Multi-user implementation tests (local_trusted mode). + * + * Covers: + * 1. Company member management API (list, update role, suspend) + * 2. Human invite creation and acceptance API + * 3. Company Settings UI — member list, role editing, invite creation + * 4. Invite landing page UI + * 5. Role-based access control (viewer read-only) + * 6. Last-owner protection + */ + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3104"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Ensure the server is bootstrapped (claimed) before running tests. */ +async function ensureBootstrapped(request: APIRequestContext): Promise { + const healthRes = await request.get(`${BASE}/api/health`); + const health = await healthRes.json(); + if (health.bootstrapStatus === "ready") return; + + // If bootstrap_pending, we need to use the claim token from the bootstrap invite. + // In local_trusted mode, just try hitting companies — that should auto-bootstrap. + if (health.deploymentMode === "local_trusted") { + // local_trusted should work without explicit bootstrap + return; + } +} + +/** Create a company via the onboarding wizard API shortcut. */ +async function createCompanyViaWizard( + request: APIRequestContext, + name: string +): Promise<{ companyId: string; agentId: string; prefix: string }> { + await ensureBootstrapped(request); + + const createRes = await request.post(`${BASE}/api/companies`, { + data: { name }, + }); + if (!createRes.ok()) { + const errText = await createRes.text(); + throw new Error( + `Failed to create company (${createRes.status()}): ${errText}` + ); + } + const company = await createRes.json(); + + // Create a CEO agent + const agentRes = await request.post( + `${BASE}/api/companies/${company.id}/agents`, + { + data: { + name: "CEO", + role: "ceo", + title: "CEO", + adapterType: "claude_local", + }, + } + ); + expect(agentRes.ok()).toBe(true); + const agent = await agentRes.json(); + + return { + companyId: company.id, + agentId: agent.id, + prefix: company.issuePrefix ?? company.id, + }; +} + +/** Create a human invite and return token + invite URL. */ +async function createHumanInvite( + request: APIRequestContext, + companyId: string, + role: string = "operator" +): Promise<{ token: string; inviteUrl: string; inviteId: string }> { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: role, + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + return { + token: body.token, + inviteUrl: body.inviteUrl, + inviteId: body.id, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Multi-user: API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-API-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("GET /companies/:id/members returns member list with access info", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("members"); + expect(body).toHaveProperty("access"); + expect(Array.isArray(body.members)).toBe(true); + expect(body.access).toHaveProperty("currentUserRole"); + expect(body.access).toHaveProperty("canManageMembers"); + expect(body.access).toHaveProperty("canInviteUsers"); + }); + + test("POST /companies/:id/invites creates a human invite with role", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: "operator", + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body.inviteUrl).toContain("/invite/"); + }); + + test("GET /invites/:token returns invite summary", async ({ request }) => { + const invite = await createHumanInvite(request, companyId, "viewer"); + const res = await request.get(`${BASE}/api/invites/${invite.token}`); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("companyId"); + expect(body).toHaveProperty("allowedJoinTypes"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body).toHaveProperty("inviteType"); + expect(body.inviteType).toBe("company_join"); + }); + + test("POST /invites/:token/accept (human) creates membership", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "human" }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + + // In local_trusted, human accept should succeed + expect(body).toHaveProperty("id"); + }); + + test("POST /invites/:token/accept rejects agent on human-only invite", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "agent", agentName: "Rogue" }, + } + ); + expect(acceptRes.ok()).toBe(false); + expect(acceptRes.status()).toBe(400); + }); + + test("POST /companies/:id/invites supports all four roles", async ({ + request, + }) => { + for (const role of ["owner", "admin", "operator", "viewer"]) { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { allowedJoinTypes: "human", humanRole: role }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.token).toBeTruthy(); + } + }); + + test("PATCH /companies/:id/members/:memberId cannot remove last owner", async ({ + request, + }) => { + // Create a fresh company for this test + const fresh = await createCompanyViaWizard( + request, + `MU-LastOwner-${Date.now()}` + ); + + // First promote the local-board member to owner + const membersRes = await request.get( + `${BASE}/api/companies/${fresh.companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find the board member (should be the only one) + const boardMember = members.find( + (m: { principalId: string }) => m.principalId === "local-board" + ); + if (!boardMember) { + test.skip(); + return; + } + + // Promote to owner first + const promoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "owner" } } + ); + expect(promoteRes.ok()).toBe(true); + + // Now try to demote the last (and only) owner to operator — should fail + const demoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "operator" } } + ); + expect(demoteRes.status()).toBe(409); + const errBody = await demoteRes.json(); + expect(JSON.stringify(errBody)).toContain("last active owner"); + }); + + test("POST /companies/:id/openclaw/invite-prompt creates agent invite", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { + data: { agentMessage: "E2E test agent invite" }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("agent"); + }); +}); + +test.describe("Multi-user: Company Settings UI", () => { + let companyId: string; + let companyPrefix: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-UI-${Date.now()}` + ); + companyId = result.companyId; + companyPrefix = result.prefix; + }); + + test("shows Team and Invites sections on settings page", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("company-settings-team-section")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("shows human invite creation controls", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + + const roleSelect = page.getByTestId("company-settings-human-invite-role"); + await expect(roleSelect).toBeVisible(); + }); + + test("can create human invite and shows URL", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + await inviteButton.click(); + + await expect(page.getByTestId("company-settings-human-invite-url")).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe("Multi-user: Invite Landing UI", () => { + let companyId: string; + let inviteToken: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Invite-${Date.now()}` + ); + companyId = result.companyId; + + const invite = await createHumanInvite(request, companyId, "operator"); + inviteToken = invite.token; + }); + + test("invite landing page loads with join options", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // Should show the invite landing page heading + await expect( + page.getByRole("heading", { name: /join/i }) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("invite landing shows human join type", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // For a human-only invite, should show human join option + const humanOption = page.locator("text=/human/i"); + await expect(humanOption).toBeVisible({ timeout: 10_000 }); + }); + + test("expired/invalid invite token returns error", async ({ page }) => { + await page.goto(`${BASE}/invite/invalid-token-e2e-test`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("invite-error")).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Multi-user: Member role management API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Roles-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("invite + accept creates member with correct role", async ({ + request, + }) => { + // Create invite for 'viewer' role + const invite = await createHumanInvite(request, companyId, "viewer"); + + // Accept the invite + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // Check members list + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Should have at least one member (the creator/local-board) + expect(members.length).toBeGreaterThanOrEqual(1); + }); + + test("PATCH member role updates correctly", async ({ request }) => { + // First create an invite and accept it to get a second member + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // List members + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find a non-owner member to modify + const nonOwner = members.find( + (m: { membershipRole: string }) => m.membershipRole !== "owner" + ); + if (!nonOwner) { + test.skip(); + return; + } + + // Update role to admin + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { membershipRole: "admin" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.membershipRole).toBe("admin"); + }); + + test("PATCH member status to suspended works", async ({ request }) => { + // Create another member + const invite = await createHumanInvite(request, companyId, "operator"); + await request.post(`${BASE}/api/invites/${invite.token}/accept`, { + data: { requestType: "human" }, + }); + + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + const nonOwner = members.find( + (m: { membershipRole: string; status: string }) => + m.membershipRole !== "owner" && m.status === "active" + ); + if (!nonOwner) { + test.skip(); + return; + } + + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { status: "suspended" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.status).toBe("suspended"); + }); +}); + +test.describe("Multi-user: Agent invite flow", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Agent-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("agent invite accept creates pending join request", async ({ + request, + }) => { + // Create agent invite + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { data: {} } + ); + expect(res.ok()).toBe(true); + const { token } = await res.json(); + + // Accept as agent + const acceptRes = await request.post( + `${BASE}/api/invites/${token}/accept`, + { + data: { + requestType: "agent", + agentName: "TestAgent", + adapterType: "claude_local", + }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + expect(body).toHaveProperty("id"); + expect(body.status).toBe("pending_approval"); + }); + + test("join requests list shows pending agent request", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/join-requests?status=pending_approval` + ); + expect(res.ok()).toBe(true); + const requests = await res.json(); + expect(Array.isArray(requests)).toBe(true); + }); +}); + +test.describe("Multi-user: Health check integration", () => { + test("health endpoint reports deployment mode", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body).toHaveProperty("deploymentMode"); + expect(body).toHaveProperty("authReady"); + expect(body.authReady).toBe(true); + }); +}); diff --git a/tests/e2e/playwright-multiuser.config.ts b/tests/e2e/playwright-multiuser.config.ts new file mode 100644 index 00000000000..0c2d79be1a7 --- /dev/null +++ b/tests/e2e/playwright-multiuser.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3104); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // No webServer — expects an already-running server at BASE_URL. + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +});