From 6d0522109992494cec9035fd4e3e1dc6bcbb673a Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 31 Mar 2026 12:02:27 -0500 Subject: [PATCH] Add authenticated multi-user E2E coverage Co-Authored-By: Paperclip --- package.json | 1 + .../scripts/create-auth-bootstrap-invite.ts | 84 +++++ tests/e2e/multi-user-authenticated.spec.ts | 322 ++++++++++++++++++ ...aywright-multiuser-authenticated.config.ts | 28 ++ 4 files changed, 435 insertions(+) create mode 100644 packages/db/scripts/create-auth-bootstrap-invite.ts create mode 100644 tests/e2e/multi-user-authenticated.spec.ts create mode 100644 tests/e2e/playwright-multiuser-authenticated.config.ts diff --git a/package.json b/package.json index 311a092f1af..3c083e44b91 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", + "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", diff --git a/packages/db/scripts/create-auth-bootstrap-invite.ts b/packages/db/scripts/create-auth-bootstrap-invite.ts new file mode 100644 index 00000000000..7af8a7d34f6 --- /dev/null +++ b/packages/db/scripts/create-auth-bootstrap-invite.ts @@ -0,0 +1,84 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { createDb } from "../src/client.js"; +import { invites } from "../src/schema/index.js"; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createInviteToken() { + return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; +} + +function readArg(flag: string) { + const index = process.argv.indexOf(flag); + if (index === -1) return null; + return process.argv[index + 1] ?? null; +} + +async function main() { + const configPath = readArg("--config"); + const baseUrl = readArg("--base-url"); + + if (!configPath || !baseUrl) { + throw new Error("Usage: tsx create-auth-bootstrap-invite.ts --config --base-url "); + } + + const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as { + database?: { + mode?: string; + embeddedPostgresPort?: number; + connectionString?: string; + }; + }; + const dbUrl = + config.database?.mode === "postgres" + ? config.database.connectionString + : `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`; + if (!dbUrl) { + throw new Error(`Could not resolve database connection from ${configPath}`); + } + + const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; + + try { + const now = new Date(); + await db + .update(invites) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now) + ) + ); + + const token = createInviteToken(); + await db.insert(invites).values({ + inviteType: "bootstrap_ceo", + tokenHash: hashToken(token), + allowedJoinTypes: "human", + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + invitedByUserId: "system", + }); + + process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/tests/e2e/multi-user-authenticated.spec.ts b/tests/e2e/multi-user-authenticated.spec.ts new file mode 100644 index 00000000000..7055d206b37 --- /dev/null +++ b/tests/e2e/multi-user-authenticated.spec.ts @@ -0,0 +1,322 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { test, expect, type Browser, type Page } from "@playwright/test"; + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3105"; +const DATA_DIR = process.env.PAPERCLIP_E2E_DATA_DIR ?? process.env.PAPERCLIP_HOME; +const CONFIG_PATH = process.env.PAPERCLIP_E2E_CONFIG_PATH ?? path.resolve(process.cwd(), ".paperclip/config.json"); +const BOOTSTRAP_SCRIPT_PATH = path.resolve(process.cwd(), "packages/db/scripts/create-auth-bootstrap-invite.ts"); +const OWNER_PASSWORD = "paperclip-owner-password"; +const INVITED_PASSWORD = "paperclip-invited-password"; + +type HumanUser = { + name: string; + email: string; + password: string; +}; + +type CompanySummary = { + id: string; + name: string; + issuePrefix?: string | null; +}; + +type CompanyMember = { + id: string; + membershipRole: "owner" | "admin" | "operator" | "viewer"; + status: "pending" | "active" | "suspended"; + user: { id: string; email: string | null; name: string | null } | null; +}; + +type SessionJsonResponse = { + ok: boolean; + status: number; + text: string; + json: T | null; +}; + +const runId = Date.now(); +const companyName = `MU-Auth-${runId}`; +const ownerUser: HumanUser = { + name: "Owner User", + email: `owner-${runId}@paperclip.local`, + password: OWNER_PASSWORD, +}; +const invitedUser: HumanUser = { + name: "Invited User", + email: `invitee-${runId}@paperclip.local`, + password: INVITED_PASSWORD, +}; + +function createBootstrapInvite() { + if (!DATA_DIR) { + throw new Error("PAPERCLIP_E2E_DATA_DIR or PAPERCLIP_HOME is required for authenticated bootstrap tests"); + } + if (!existsSync(CONFIG_PATH)) { + throw new Error(`Authenticated bootstrap config not found at ${CONFIG_PATH}`); + } + if (!existsSync(BOOTSTRAP_SCRIPT_PATH)) { + throw new Error(`Authenticated bootstrap helper not found at ${BOOTSTRAP_SCRIPT_PATH}`); + } + + const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + return execFileSync( + pnpmCommand, + [ + "--filter", + "@paperclipai/db", + "exec", + "tsx", + BOOTSTRAP_SCRIPT_PATH, + "--config", + CONFIG_PATH, + "--base-url", + BASE, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + FORCE_COLOR: "0", + NO_COLOR: "1", + PAPERCLIP_HOME: DATA_DIR, + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ).trim(); +} + +async function signUp(page: Page, user: HumanUser) { + await page.goto(`${BASE}/auth`); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await expect(page.getByRole("heading", { name: "Create your Paperclip account" })).toBeVisible(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 }); +} + +async function acceptBootstrapInvite(page: Page, inviteUrl: string) { + await page.goto(inviteUrl); + await expect(page.getByRole("heading", { name: "Bootstrap your Paperclip instance" })).toBeVisible(); + await page.getByRole("button", { name: "Accept bootstrap invite" }).click(); + await expect(page.getByRole("heading", { name: "Bootstrap complete" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function createCompanyForSession(page: Page, nextCompanyName: string) { + const createRes = await sessionJsonRequest(page, `${BASE}/api/companies`, { + method: "POST", + data: { name: nextCompanyName }, + }); + expect(createRes.ok).toBe(true); + expect(createRes.json).toBeTruthy(); + return createRes.json!; +} + +async function createAuthenticatedInvite(page: Page, companyPrefix: string) { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 20_000, + }); + await page.getByTestId("company-settings-human-invite-role").selectOption("operator"); + await page.getByTestId("company-settings-create-human-invite").click(); + const inviteField = page.getByTestId("company-settings-human-invite-url"); + await expect(inviteField).toBeVisible({ timeout: 20_000 }); + return (await inviteField.inputValue()).trim(); +} + +async function signUpFromInvite(page: Page, inviteUrl: string, user: HumanUser) { + await page.goto(inviteUrl); + await expect(page.getByText("Sign in or create an account before submitting a human join request.")).toBeVisible(); + await page.getByRole("link", { name: "Sign in / Create account" }).click(); + await expect(page).toHaveURL(/\/auth\?next=/); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).toHaveURL(new RegExp(`/invite/[^/]+$`), { timeout: 20_000 }); +} + +async function acceptHumanInvite(page: Page) { + await expect(page.getByRole("button", { name: "Join company" })).toBeEnabled(); + await page.getByRole("button", { name: "Join company" }).click(); + await expect(page.getByRole("heading", { name: "You joined the company" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function sessionJsonRequest( + page: Page, + url: string, + options: { + method?: string; + data?: unknown; + } = {} +) { + return page.evaluate( + async ({ url: targetUrl, method, data }) => { + const response = await fetch(targetUrl, { + method, + credentials: "include", + headers: data === undefined ? undefined : { "Content-Type": "application/json" }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + const text = await response.text(); + let json: unknown = null; + if (text.length > 0) { + try { + json = JSON.parse(text); + } catch { + json = null; + } + } + return { + ok: response.ok, + status: response.status, + text, + json, + }; + }, + { + url, + method: options.method ?? "GET", + data: options.data, + } + ) as Promise>; +} + +async function waitForMember(page: Page, companyId: string, email: string) { + let member: CompanyMember | null = null; + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + member = body.members.find((entry) => entry.user?.email === email) ?? null; + return member; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + status: "active", + membershipRole: "operator", + user: { email }, + }); + return member!; +} + +async function waitForMemberRole( + page: Page, + companyId: string, + memberId: string, + membershipRole: CompanyMember["membershipRole"] +) { + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + return body.members.find((member) => member.id === memberId) ?? null; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + id: memberId, + membershipRole, + }); +} + +async function newPage(browser: Browser) { + const context = await browser.newContext(); + const page = await context.newPage(); + return { context, page }; +} + +test.describe("Multi-user: authenticated mode", () => { + test("authenticated humans can bootstrap, invite, join, and respect viewer restrictions", async ({ + browser, + page, + }) => { + test.setTimeout(180_000); + + const healthRes = await page.request.get(`${BASE}/api/health`); + expect(healthRes.ok()).toBe(true); + const health = (await healthRes.json()) as { + deploymentMode?: string; + bootstrapStatus?: string; + }; + expect(health.deploymentMode).toBe("authenticated"); + + await signUp(page, ownerUser); + await acceptBootstrapInvite(page, createBootstrapInvite()); + + const company = await createCompanyForSession(page, companyName); + const companyPrefix = company.issuePrefix ?? company.id; + const inviteUrl = await createAuthenticatedInvite(page, companyPrefix); + + const invited = await newPage(browser); + try { + await signUpFromInvite(invited.page, inviteUrl, invitedUser); + await acceptHumanInvite(invited.page); + await expect(invited.page).not.toHaveURL(/\/auth/, { timeout: 10_000 }); + + const joinedMember = await waitForMember(page, company.id, invitedUser.email); + + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + const roleSelect = page.getByTestId(`company-settings-member-role-${joinedMember.id}`); + await expect(roleSelect).toBeVisible({ timeout: 20_000 }); + await roleSelect.selectOption("viewer"); + await expect(roleSelect).toHaveValue("viewer"); + await waitForMemberRole(page, company.id, joinedMember.id, "viewer"); + + await invited.page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect( + invited.page.getByText("Your current company role cannot create human invites.") + ).toBeVisible({ timeout: 20_000 }); + await expect( + invited.page.getByTestId("company-settings-create-human-invite") + ).toHaveCount(0); + + const forbiddenInviteRes = await sessionJsonRequest( + invited.page, + `${BASE}/api/companies/${company.id}/invites`, + { + method: "POST", + data: { + allowedJoinTypes: "human", + humanRole: "viewer", + }, + } + ); + expect(forbiddenInviteRes.status).toBe(403); + } finally { + await invited.context.close(); + } + }); +}); diff --git a/tests/e2e/playwright-multiuser-authenticated.config.ts b/tests/e2e/playwright-multiuser-authenticated.config.ts new file mode 100644 index 00000000000..f80f13372ad --- /dev/null +++ b/tests/e2e/playwright-multiuser-authenticated.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3105); +const BASE_URL = process.env.PAPERCLIP_E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user-authenticated.spec.ts", + timeout: 180_000, + expect: { + timeout: 20_000, + }, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +});