后端:User.is_platform_admin + migration;权限类 IsPlatformAdmin;管理命令建 admin/admin123(幂等); AdminAuditLog 模型 + log_admin_action() helper;me/login 对无团队超管优雅返回 team=null;UserSerializer 暴露标志。 前端:routes/admin 后台外壳(分组侧栏 + 概览 + 占位)、/admin 路由解析与 gating(超管直落、非超管纠回)、 侧栏平台入口、admin-page.css(仅 token)、IconKitSvg 补图标。 测试:accounts 11/11 单测过;无头 e2e _admin-p0.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
6.0 KiB
JavaScript
121 lines
6.0 KiB
JavaScript
// Phase 0 e2e:平台超管后台基础壳 + 路由 gating。
|
|
// 沿用 _wave35-auth.mjs 模式:headless 启动 → token 注入 → 真点击走查 → locator 断言 + 截图 + 抓 console/pageerror。
|
|
// 跑法:cd core/qa/visual-parity && node _admin-p0.mjs (需 vite 5173 + backend 8010 在跑)
|
|
import { chromium } from "playwright";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const BASE = process.env.BASE || "http://localhost:5173";
|
|
const API = process.env.API || "http://127.0.0.1:8010";
|
|
const OUT = path.resolve("output/admin");
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
|
|
async function apiLogin(username, password) {
|
|
const res = await fetch(`${API}/api/auth/login/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) throw new Error(`login ${username} -> ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
const r = { realLogin: {}, adminShell: {}, gating: {}, consoleErrors: [], pass: false };
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
// 取 token(超管 / 普通)
|
|
const admin = await apiLogin("admin", "admin123");
|
|
const normal = await apiLogin("airshelf", "Restraint2026");
|
|
r.tokens = { adminFlag: admin.user.is_platform_admin, adminTeam: admin.team, normalFlag: normal.user.is_platform_admin };
|
|
|
|
// ── A. 真登录:admin/admin123 → 应跳 /admin ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const p = await ctx.newPage();
|
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("realLogin:" + m.text()); });
|
|
p.on("pageerror", (e) => r.consoleErrors.push("realLogin:PAGEERR:" + e.message));
|
|
await p.goto(BASE + "/login", { waitUntil: "load" });
|
|
await p.waitForTimeout(400);
|
|
await p.fill("#auth-username", "admin");
|
|
await p.fill("#auth-pwd", "admin123");
|
|
await p.click("button.btn-cta");
|
|
try {
|
|
await p.waitForFunction(() => location.pathname.startsWith("/admin"), { timeout: 12000 });
|
|
r.realLogin.landedOnAdmin = true;
|
|
} catch { r.realLogin.landedOnAdmin = false; }
|
|
await p.waitForTimeout(500);
|
|
r.realLogin.url = p.url();
|
|
r.realLogin.adminAppShown = (await p.locator(".admin-app").count()) === 1;
|
|
await p.screenshot({ path: path.join(OUT, "p0-real-login-admin.png") });
|
|
await ctx.close();
|
|
}
|
|
|
|
// ── B. token 注入超管 → /admin 后台壳走查 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
|
const p = await ctx.newPage();
|
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("adminShell:" + m.text()); });
|
|
p.on("pageerror", (e) => r.consoleErrors.push("adminShell:PAGEERR:" + e.message));
|
|
await p.goto(BASE + "/admin", { waitUntil: "load" });
|
|
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
|
await p.waitForTimeout(400);
|
|
r.adminShell.appShown = (await p.locator(".admin-app").count()) === 1;
|
|
r.adminShell.sidebarShown = (await p.locator(".admin-sidebar").count()) === 1;
|
|
r.adminShell.badge = (await p.locator(".admin-badge").innerText().catch(() => "")).trim();
|
|
r.adminShell.navGroups = await p.locator(".admin-nav-group").count();
|
|
r.adminShell.navLinks = await p.locator(".admin-sidebar nav a").count();
|
|
r.adminShell.overviewTitle = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
|
r.adminShell.shortcuts = await p.locator(".admin-shortcut-grid .shortcut").count();
|
|
await p.screenshot({ path: path.join(OUT, "p0-admin-overview.png"), fullPage: true });
|
|
|
|
// 点「邀请码」section → 应到 /admin/invites,占位「模块即将上线」
|
|
await p.locator('.admin-sidebar nav a[href="/admin/invites"]').click();
|
|
await p.waitForFunction(() => location.pathname === "/admin/invites", { timeout: 6000 }).catch(() => {});
|
|
await p.waitForTimeout(300);
|
|
r.adminShell.sectionUrl = new URL(p.url()).pathname;
|
|
r.adminShell.placeholderShown = (await p.locator(".empty-state.show").count()) >= 1;
|
|
r.adminShell.placeholderText = (await p.locator(".empty-state h3").innerText().catch(() => "")).trim();
|
|
await p.screenshot({ path: path.join(OUT, "p0-admin-section-placeholder.png") });
|
|
await ctx.close();
|
|
}
|
|
|
|
// ── C. token 注入普通用户 → /admin 应被纠回工作台 ──
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), normal.token);
|
|
const p = await ctx.newPage();
|
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("gating:" + m.text()); });
|
|
p.on("pageerror", (e) => r.consoleErrors.push("gating:PAGEERR:" + e.message));
|
|
await p.goto(BASE + "/admin", { waitUntil: "load" });
|
|
await p.waitForTimeout(1500);
|
|
r.gating.url = p.url();
|
|
r.gating.pathname = new URL(p.url()).pathname;
|
|
r.gating.redirectedAway = !new URL(p.url()).pathname.startsWith("/admin");
|
|
r.gating.adminAppAbsent = (await p.locator(".admin-app").count()) === 0;
|
|
await p.screenshot({ path: path.join(OUT, "p0-gating-normal-user.png") });
|
|
await ctx.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// 判定
|
|
const checks = {
|
|
adminFlagTrue: r.tokens.adminFlag === true,
|
|
adminTeamNull: r.tokens.adminTeam === null,
|
|
normalFlagNotTrue: !r.tokens.normalFlag,
|
|
realLoginLandedAdmin: r.realLogin.landedOnAdmin === true && r.realLogin.adminAppShown === true,
|
|
shellShown: r.adminShell.appShown && r.adminShell.sidebarShown,
|
|
navComplete: r.adminShell.navGroups === 6 && r.adminShell.navLinks === 11,
|
|
overviewTitle: r.adminShell.overviewTitle === "平台概览",
|
|
placeholderWorks: r.adminShell.sectionUrl === "/admin/invites" && r.adminShell.placeholderShown,
|
|
gatingRedirect: r.gating.redirectedAway === true && r.gating.adminAppAbsent === true,
|
|
zeroConsoleErrors: r.consoleErrors.length === 0,
|
|
};
|
|
r.checks = checks;
|
|
r.pass = Object.values(checks).every(Boolean);
|
|
|
|
console.log(JSON.stringify(r, null, 2));
|
|
fs.writeFileSync(path.join(OUT, "p0-summary.json"), JSON.stringify(r, null, 2));
|
|
process.exit(r.pass ? 0 : 1);
|