166 lines
7.1 KiB
TypeScript
166 lines
7.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
|
|
|
const highRiskMappings = [
|
|
{
|
|
name: "商品保存",
|
|
ui: "src/components/admin/product-management.tsx",
|
|
api: "src/app/api/admin/products/route.ts",
|
|
permission: "billing.products.write",
|
|
},
|
|
{
|
|
name: "商品发布",
|
|
ui: "src/components/admin/product-management.tsx",
|
|
api: "src/app/api/admin/products/route.ts",
|
|
permission: "billing.products.publish",
|
|
},
|
|
{
|
|
name: "订阅调整",
|
|
ui: "src/components/admin/billing-operations-resources.tsx",
|
|
api: "src/app/api/admin/subscriptions/route.ts",
|
|
permission: "billing.adjustments.write",
|
|
},
|
|
{
|
|
name: "订单调整",
|
|
ui: "src/components/admin/billing-operations-resources.tsx",
|
|
api: "src/app/api/admin/orders/route.ts",
|
|
permission: "billing.adjustments.write",
|
|
},
|
|
{
|
|
name: "兑换码创建",
|
|
ui: "src/components/admin/codes-resource.tsx",
|
|
api: "src/app/api/admin/codes/route.ts",
|
|
permission: "billing.adjustments.write",
|
|
},
|
|
{
|
|
name: "兑换码编辑与撤销",
|
|
ui: "src/components/admin/codes-resource.tsx",
|
|
api: "src/app/api/admin/codes/[id]/route.ts",
|
|
permission: "billing.adjustments.write",
|
|
},
|
|
{
|
|
name: "功能开关发布",
|
|
ui: "src/components/admin/feature-flags-management.tsx",
|
|
api: "src/app/api/admin/feature-flags/route.ts",
|
|
permission: "ops.flags.write",
|
|
},
|
|
{
|
|
name: "易支付设置保存",
|
|
ui: "src/components/admin/payment-management.tsx",
|
|
api: "src/app/api/admin/epay-settings/route.ts",
|
|
permission: "billing.adjustments.write",
|
|
},
|
|
{
|
|
name: "管理员角色变更",
|
|
ui: "src/components/admin/administrators-resource.tsx",
|
|
api: "src/app/api/admin/administrators/route.ts",
|
|
permission: "admin.users.manage_roles",
|
|
},
|
|
] as const;
|
|
|
|
test("high-risk UI reauth permissions match their API guards", async (t) => {
|
|
for (const mapping of highRiskMappings) {
|
|
await t.test(mapping.name, () => {
|
|
const ui = source(mapping.ui);
|
|
const api = source(mapping.api);
|
|
assert.match(ui, /ReasonActionModal/);
|
|
assert.match(ui, new RegExp(`reauthPermission=[\\s\\S]{0,80}["']${mapping.permission.replaceAll(".", "\\.")}["']|reauthPermission="${mapping.permission.replaceAll(".", "\\.")}"`));
|
|
assert.match(api, new RegExp(`requireHighRiskAdminMutation\\(\\s*request,\\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\\s*\\)`));
|
|
assert.match(ui, new RegExp(`permissions\\.includes\\(["']${mapping.permission.replaceAll(".", "\\.")}["']\\)`));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("product management localizes product, billing and entitlement enums", () => {
|
|
const ui = source("src/components/admin/product-management.tsx");
|
|
|
|
for (const label of [
|
|
"点数包",
|
|
"体验套餐",
|
|
"订阅套餐",
|
|
"一次性",
|
|
"按天",
|
|
"按月",
|
|
"按年",
|
|
"使用权限",
|
|
"不限量",
|
|
"限额",
|
|
"点数",
|
|
"不重置",
|
|
"每个计费周期",
|
|
]) {
|
|
assert.match(ui, new RegExp(label));
|
|
}
|
|
assert.doesNotMatch(ui, /<Tag>\{value\}<\/Tag>/);
|
|
assert.doesNotMatch(ui, /\$\{item\.intervalCount\} \$\{item\.billingPeriod\}/);
|
|
});
|
|
|
|
test("model management uses ordinary admin mutation guards without reauth", () => {
|
|
const ui = source("src/components/admin/model-management.tsx");
|
|
const route = source("src/app/api/admin/models/route.ts");
|
|
const discoverRoute = source("src/app/api/admin/models/discover/route.ts");
|
|
|
|
for (const permission of ["models.write", "models.test", "models.publish", "models.rollback"]) {
|
|
assert.ok(ui.includes(`permissions.includes("${permission}")`));
|
|
}
|
|
assert.match(route, /requireAdminMutation\(request, permission\)/);
|
|
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
|
|
assert.match(discoverRoute, /requireAdminMutation\(request,"models\.test"\)/);
|
|
assert.doesNotMatch(discoverRoute, /requireHighRiskAdminMutation/);
|
|
assert.doesNotMatch(ui, /reauthPermission=[^\n]*models\./);
|
|
assert.doesNotMatch(ui, /验证并(?:保存|获取)/);
|
|
});
|
|
|
|
test("admin request failures are real Error instances and reason modal keeps failures visible", () => {
|
|
const providers = source("src/lib/admin/providers.ts");
|
|
const modal = source("src/components/admin/reason-action-modal.tsx");
|
|
assert.match(
|
|
providers,
|
|
/Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/,
|
|
);
|
|
assert.doesNotMatch(providers, /throw \{ message, statusCode/);
|
|
assert.match(modal, /catch \(error\)[\s\S]*setReauthError\(error instanceof Error \? error\.message/);
|
|
assert.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/);
|
|
assert.match(modal, /await onSubmit\(reason\.trim\(\)\)/);
|
|
});
|
|
|
|
test("customer list is always masked and a single explicit reveal is audited each time", () => {
|
|
const providers = source("src/lib/admin/providers.ts");
|
|
const route = source("src/app/api/admin/customers/route.ts");
|
|
const ui = source("src/components/admin/users-resource.tsx");
|
|
const migration = source("supabase/migrations/20260806010000_admin_rbac.sql");
|
|
|
|
assert.match(providers, /customers: \{ read: "admin\.customers\.read" \}/);
|
|
assert.match(route, /revealUserId[\s\S]*requirePermission\("admin\.customers\.birth_data\.read"\)/);
|
|
assert.match(route, /\[session\.user\.id, \[parsedUserId\.data\], requestId\(request\)\]/);
|
|
const listBranch = route.slice(route.indexOf('await requirePermission("admin.customers.read")'));
|
|
assert.doesNotMatch(listBranch, /admin_read_customer_birth_data/);
|
|
assert.match(listBranch, /birthDate: null[\s\S]*birthTimeStatus: null[\s\S]*birthPlace: null[\s\S]*birthDataMasked: true/);
|
|
assert.match(migration, /cardinality\(p_target_user_ids\) <> 1/);
|
|
assert.match(ui, /revealUserId=\$\{encodeURIComponent\(userId\)\}/);
|
|
assert.match(ui, /再次读取并审计/);
|
|
assert.match(ui, /admin\.customers\.birth_data\.read/);
|
|
});
|
|
|
|
test("administrator UI supports all six roles and exposes last-owner protection", () => {
|
|
const ui = source("src/components/admin/administrators-resource.tsx");
|
|
const route = source("src/app/api/admin/administrators/route.ts");
|
|
const roles = source("src/components/admin/roles-resource.tsx");
|
|
for (const role of ["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]) {
|
|
assert.match(ui, new RegExp(`["']${role}["']`));
|
|
}
|
|
assert.match(ui, /method: pendingAction\.action === "assign" \? "POST" : "DELETE"/);
|
|
const mutationSection = route.slice(route.indexOf("async function mutate"));
|
|
assert.match(mutationSection, /last_owner_protected/);
|
|
assert.match(mutationSection, /不能撤销最后一位 Owner,请先分配另一位 Owner/);
|
|
assert.doesNotMatch(route.slice(0, route.indexOf("async function mutate")), /last_owner_protected/);
|
|
assert.match(roles, /系统预置只读矩阵/);
|
|
assert.match(roles, /不提供角色权限矩阵写 API/);
|
|
const providers = source("src/lib/admin/providers.ts");
|
|
assert.match(providers, /roles: \{ read: "admin\.users\.read" \}/);
|
|
assert.doesNotMatch(providers, /roles: \{[^}]*write:/);
|
|
});
|