Files
yingqing/core/qa/visual-parity/_admin-p0.mjs
T
seaislee1209andClaude Opus 4.8 443023a1a9 feat(admin): Phase 0 平台超管基础 — is_platform_admin + IsPlatformAdmin + create_platform_admin(admin/admin123) + AdminAuditLog + Admin 后台外壳与路由 gating
后端: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>
2026-06-19 20:49:34 +08:00

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);