import { chromium, request } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const runId = `slice6-role-${Date.now()}`; const webBase = process.env.SLICE6_WEB_URL || "http://localhost:3000"; const apiBase = process.env.SLICE6_API_URL || "http://127.0.0.1:8010/api/v1"; const healthUrl = process.env.SLICE6_HEALTH_URL || "http://127.0.0.1:8010/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.SLICE6_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice6-role-pilot-user-closed-loop", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const results = []; const state = { api: null, browser: null, contexts: [], admin: null, legal: null, business: null, legalEmail: `slice6-legal-${Date.now()}@example.com`, businessEmail: `slice6-business-${Date.now()}@example.com`, registeredEmail: `slice6-register-${Date.now()}@example.com`, password: "slice6Pass123", }; function apiUrl(route) { return `${apiBase.replace(/\/$/, "")}${route}`; } function screenshotPath(name) { return path.join(artifactDir, `${name}.png`); } function assert(condition, message, details = undefined) { if (!condition) { const error = new Error(message); error.details = details; throw error; } } async function step(id, title, fn) { const startedAt = new Date().toISOString(); const started = Date.now(); try { const details = await fn(); const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; results.push(item); console.log(`PASS ${id} ${title}`); return item; } catch (error) { const item = { id, title, status: "FAIL", duration_ms: Date.now() - started, started_at: startedAt, error: error.message, details: error.details || {}, }; results.push(item); console.error(`FAIL ${id} ${title}: ${error.message}`); return item; } } async function apiRequest(method, route, options = {}) { const headers = { ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), ...(options.headers || {}), }; const response = await state.api[method](apiUrl(route), { ...options, headers }); const text = await response.text(); let body = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } } return { response, body, text }; } function expectOk(payload, label) { assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { body: payload.body, }); if (payload.body && typeof payload.body === "object" && "code" in payload.body) { assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); } } async function login(account, password) { const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); expectOk(payload, `login ${account}`); return payload.body.data; } async function createPilotUser(username, role, displayName) { const payload = await apiRequest("post", "/users", { authToken: state.admin.access_token, multipart: { username, display_name: displayName, password: state.password, role, status: "active", }, }); expectOk(payload, `create ${role} user`); assert(payload.body.data.role === role, `${role} role mismatch`, payload.body.data); return payload.body.data; } async function attachApiProxy(context) { await context.route("**/api/v1/**", async (route) => { const browserRequest = route.request(); const browserUrl = new URL(browserRequest.url()); const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); const headers = { ...browserRequest.headers() }; delete headers.host; const response = await state.api.fetch(targetUrl, { method: browserRequest.method(), headers, data: browserRequest.postDataBuffer() ?? undefined, }); await route.fulfill({ response }); }); } async function createAuthenticatedContext(userSession) { const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); await context.addInitScript( ({ token, user }) => { window.localStorage.setItem("access_token", token); window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); }, { token: userSession.access_token, user: userSession.user }, ); await attachApiProxy(context); state.contexts.push(context); return context; } async function navLinkCount(page, href) { return page.locator(`.app-nav a[href="${href}"]`).count(); } async function run() { state.api = await request.newContext(); state.browser = await chromium.launch({ headless: true }); await step("ENV-001", "frontend and backend health", async () => { const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); assert(health.status() === 200, `backend /health status ${health.status()}`); return { webBase, apiBase, healthUrl, artifactDir }; }); await step("AUTH-001", "default admin seed and login", async () => { const seed = await apiRequest("post", "/auth/seed-admin"); expectOk(seed, "seed default admin"); state.admin = await login("admin@example.com", adminPassword); assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); return { user: state.admin.user.username, role: state.admin.user.role }; }); await step("ADMIN-001", "admin creates legal and business pilot users", async () => { const legal = await createPilotUser(state.legalEmail, "legal", "Slice 6 Legal User"); const business = await createPilotUser(state.businessEmail, "business", "Slice 6 Business User"); const list = await apiRequest("get", "/users?page=1&page_size=20", { authToken: state.admin.access_token }); expectOk(list, "list users"); const users = list.body.data.items; assert(users.some((item) => item.username === state.legalEmail && item.role === "legal"), "legal pilot user missing", users); assert(users.some((item) => item.username === state.businessEmail && item.role === "business"), "business pilot user missing", users); return { legal_user_id: legal.id, business_user_id: business.id }; }); await step("ADMIN-002", "admin users page shows created pilot accounts", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/users`, { waitUntil: "domcontentloaded" }); await page.waitForFunction( ({ legalEmail, businessEmail }) => document.body.innerText.includes(legalEmail) && document.body.innerText.includes(businessEmail), { legalEmail: state.legalEmail, businessEmail: state.businessEmail }, { timeout: 8000 }, ); await page.screenshot({ path: screenshotPath("admin-users-pilot-roles"), fullPage: true }); return { screenshot: "admin-users-pilot-roles.png" }; }); await step("LEGAL-001", "legal user gets review-first navigation without admin menu", async () => { state.legal = await login(state.legalEmail, state.password); assert(state.legal.user.role === "legal", "legal login role mismatch", state.legal.user); const context = await createAuthenticatedContext(state.legal); const page = await context.newPage(); await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); assert((await navLinkCount(page, "/review")) >= 1, "legal nav missing review entry"); assert((await navLinkCount(page, "/contracts")) === 0, "legal nav should not expose contract generation entry"); assert((await page.locator(".app-nav-admin-trigger").count()) === 0, "legal user should not see admin menu"); await page.screenshot({ path: screenshotPath("legal-review-navigation"), fullPage: true }); return { role: state.legal.user.role }; }); await step("BUSINESS-001", "business user gets contract-first navigation without admin menu", async () => { state.business = await login(state.businessEmail, state.password); assert(state.business.user.role === "business", "business login role mismatch", state.business.user); const context = await createAuthenticatedContext(state.business); const page = await context.newPage(); await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); assert((await navLinkCount(page, "/contracts")) >= 1, "business nav missing contract generation entry"); assert((await navLinkCount(page, "/review")) === 0, "business nav should not expose direct review entry"); assert((await page.locator(".app-nav-admin-trigger").count()) === 0, "business user should not see admin menu"); await page.screenshot({ path: screenshotPath("business-contract-navigation"), fullPage: true }); return { role: state.business.user.role }; }); await step("PERM-001", "non-admin roles cannot call user management API", async () => { const [legalUsers, businessUsers] = await Promise.all([ apiRequest("get", "/users", { authToken: state.legal.access_token }), apiRequest("get", "/users", { authToken: state.business.access_token }), ]); assert(legalUsers.response.status() === 403, `legal /users should be 403, got ${legalUsers.response.status()}`, legalUsers.body); assert( businessUsers.response.status() === 403, `business /users should be 403, got ${businessUsers.response.status()}`, businessUsers.body, ); return { legal_status: legalUsers.response.status(), business_status: businessUsers.response.status() }; }); await step("SELF-REG-001", "self registration defaults to business role", async () => { const registered = await apiRequest("post", "/auth/register", { data: { username: state.registeredEmail, password: state.password, display_name: "Slice 6 Registered User", }, }); expectOk(registered, "register public user"); assert(registered.body.data.role === "business", "registered user should default to business", registered.body.data); const session = await login(state.registeredEmail, state.password); assert(session.user.role === "business", "registered login role mismatch", session.user); return { username: state.registeredEmail, role: session.user.role }; }); } try { await run(); } finally { for (const context of state.contexts) { await context.close().catch(() => {}); } if (state.browser) { await state.browser.close().catch(() => {}); } if (state.api) { await state.api.dispose().catch(() => {}); } const summary = { run_id: runId, artifact_dir: artifactDir, passed: results.filter((item) => item.status === "PASS").length, failed: results.filter((item) => item.status === "FAIL").length, results, }; fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); if (summary.failed > 0) { process.exitCode = 1; } }