/* Team-mode (M1+M2) end to end in a real browser. * * Unlike the other browser gates, this one needs a daemon started with * OPENAI4S_TEAM_MODE=1 and two seeded accounts, because the whole point is * what a *person* sees after logging in — the login redirect, the identity * chip, the admin governance panel, the guest's replay-only world, and the * cross-user refusals that a unit test can only assert at the socket. * * Setup (the harness does not seed for you — passwords must not ride argv): * OPENAI4S_DATA_DIR= OPENAI4S_TEAM_MODE=1 OPENAI4S_PORT=8794 \ * openai4s serve & * printf '\n' | openai4s user add erika --role admin --password-stdin * printf '\n' | openai4s user add mallory --password-stdin * OPENAI4S_TEAM_ADMIN_PW= OPENAI4S_TEAM_MEMBER_PW= \ * OPENAI4S_BROWSER_URL=http://127.0.0.1:8794 node tests/browser_team_mode.mjs */ import { skipFirstRunWizard } from "./browser_auth.mjs"; let playwright; try { playwright = await import("playwright"); } catch (error) { const fallback = process.env.OPENAI4S_PLAYWRIGHT_MODULE; if (!fallback) throw error; playwright = await import(fallback); } const { chromium } = playwright; const BASE = (process.env.OPENAI4S_BROWSER_URL || "http://127.0.0.1:8794").replace( /\/$/, "", ); const ADMIN = { u: process.env.OPENAI4S_TEAM_ADMIN_USER || "erika", p: process.env.OPENAI4S_TEAM_ADMIN_PW, }; const MEMBER = { u: process.env.OPENAI4S_TEAM_MEMBER_USER || "mallory", p: process.env.OPENAI4S_TEAM_MEMBER_PW, }; if (!ADMIN.p || !MEMBER.p) { console.error( "set OPENAI4S_TEAM_ADMIN_PW and OPENAI4S_TEAM_MEMBER_PW to the seeded " + "accounts' passwords (see the header comment)", ); process.exit(2); } const executablePath = process.env.OPENAI4S_BROWSER_EXECUTABLE || undefined; const browser = await chromium.launch({ headless: true, executablePath }); let failures = 0; const check = (ok, label) => { console.log((ok ? " PASS " : " FAIL ") + label); if (!ok) failures++; }; async function login(page, who) { await page.goto(BASE + "/login", { waitUntil: "domcontentloaded" }); await page.fill("#u", who.u); await page.fill("#p", who.p); await page.click("#b"); await page.waitForURL(BASE + "/", { timeout: 10000 }); await skipFirstRunWizard(page, BASE + "/"); } // -- 1. the login gate is what an anonymous visitor meets -------------------- console.log("1. login, identity chip, admin affordance"); const adminCtx = await browser.newContext(); const admin = await adminCtx.newPage(); await admin.goto(BASE + "/", { waitUntil: "domcontentloaded" }); await admin.waitForURL("**/login", { timeout: 10000 }); check(true, "anonymous root redirects to /login"); await login(admin, ADMIN); await admin.waitForSelector("#team-user:not(.hidden)", { timeout: 10000 }); const chip = await admin.textContent("#team-user"); check(chip.includes(ADMIN.u) && chip.includes("admin"), "chip names the admin"); await admin.waitForSelector("#team-admin:not(.hidden)", { timeout: 10000 }); check(true, "admin sees the Team admin button"); // -- 2. the governance panel renders live data ------------------------------ console.log("2. governance panel"); await admin.click("#team-admin"); await admin.waitForSelector("#team-admin-modal:not(.hidden)", { timeout: 5000 }); await admin.waitForSelector(".team-admin-table", { timeout: 8000 }); const panel = await admin.textContent("#team-admin-body"); check( panel.includes(ADMIN.u) && panel.includes(MEMBER.u), "user table lists the seeded accounts", ); check( /Users[\s\S]*Usage[\s\S]*Quotas[\s\S]*Invites[\s\S]*Audit/.test(panel), "all five governance sections render", ); await admin.click("#team-admin-close"); // -- 3. a member's own session ---------------------------------------------- console.log("3. member session"); const memberCtx = await browser.newContext(); const member = await memberCtx.newPage(); await login(member, MEMBER); const created = await member.evaluate(async () => { const projects = await (await fetch("/api/v1/projects")).json(); let pid = (projects.projects[0] || {}).project_id; if (!pid) { const made = await ( await fetch("/api/v1/projects", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "team-mode-e2e" }), }) ).json(); pid = made.project_id || made.id; } const frame = await ( await fetch("/api/v1/frames", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project_id: pid }), }) ).json(); return { pid, fid: frame.frame_id || frame.id }; }); check(!!created.fid, "member created a session"); // -- 4. governance is admin-only -------------------------------------------- console.log("4. governance is admin-only"); const memberAdminStatus = await member.evaluate( async () => (await fetch("/api/v1/team/users")).status, ); check(memberAdminStatus === 403, `member gets 403 on /team/users (${memberAdminStatus})`); check( await member.evaluate(() => document.getElementById("team-admin").classList.contains("hidden"), ), "member does not see the Team admin button", ); // -- 5. admin visibility ----------------------------------------------------- console.log("5. admin visibility"); check( (await admin.evaluate( async (fid) => (await fetch("/api/v1/frames/" + fid)).status, created.fid, )) === 200, "admin can read the member's session", ); // -- 6. read-only replay ----------------------------------------------------- console.log("6. read-only replay"); await member.goto(BASE + "/replay#" + created.fid, { waitUntil: "domcontentloaded" }); await member.waitForTimeout(1500); check(!(await member.textContent("#err")), "owner's replay loads without error"); check((await member.textContent("#meta")).length > 0, "replay renders metadata"); // -- 7. a guest invite yields a replay-only world --------------------------- console.log("7. guest invite"); const token = await admin.evaluate(async (pid) => { const r = await fetch("/api/v1/team/invites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project_id: pid }), }); return (await r.json()).token; }, created.pid); check(!!token, "admin minted an invite"); const guestCtx = await browser.newContext(); const guest = await guestCtx.newPage(); await guest.goto(BASE + "/login", { waitUntil: "domcontentloaded" }); const redeem = await guest.evaluate(async (t) => { const r = await fetch("/api/v1/auth/redeem-invite", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: t, username: "visitor-e2e-" + Math.floor(Date.now() / 1000), password: "fake-guest-pw-not-real", }), }); return r.status; }, token); check(redeem === 201, `invite redeemed into a guest account (${redeem})`); await guest.goto(BASE + "/", { waitUntil: "domcontentloaded" }); await guest.waitForURL("**/replay", { timeout: 10000 }); check(true, "a guest landing on / is sent to /replay"); const guestBlocked = await guest.evaluate(async () => ({ frames: (await fetch("/api/v1/frames")).status, files: (await fetch("/api/v1/files")).status, })); check( guestBlocked.frames === 403 && guestBlocked.files === 403, "a guest's API surface is closed (D3)", ); // -- 8. the isolation holes stay closed -------------------------------------- console.log("8. isolation"); const otherProject = await member.evaluate( async () => (await fetch("/api/v1/projects/proj_example")).status, ); check(otherProject === 404, `non-participant refused a project (${otherProject})`); const guestPreview = await guest.evaluate( async () => (await fetch("/preview/anything")).status, ); check( guestPreview === 403 || guestPreview === 404, "a guest cannot pull artifact bytes through /preview/", ); // -- 9. sign out -------------------------------------------------------------- console.log("9. sign out"); const loggedOut = await admin.evaluate(async () => { await fetch("/api/v1/auth/logout", { method: "POST" }); return (await fetch("/api/v1/frames")).status; }); check(loggedOut === 401, `the API refuses after logout (${loggedOut})`); await browser.close(); if (failures) { console.error(`\nteam-mode browser gate FAILED (${failures})`); process.exit(1); } console.log("\nteam-mode browser gate passed");