287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
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;
|
|
}
|
|
}
|