397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
/**
|
||
* 权限内核的可执行校验(不引测试框架,纯 TS + 断言函数)
|
||
*
|
||
* 跑法(PowerShell,在项目根目录):
|
||
* & "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
|
||
* src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
|
||
* --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
|
||
* & "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs"
|
||
*
|
||
* 为什么要它:判定语义的正确性只能靠"跑一遍"来钉住,尤其 canView 是否检 ViewIndc
|
||
* 这条——它是本次移植相对旧 WebVue 唯一的行为变更,靠读代码很容易自我说服"应该没问题"。
|
||
* 项目目前还没接测试运行器,所以先写成"能被 esbuild 打包直接用 node 跑"的形态:
|
||
* 将来引入 vitest,只需把 check() 换成 expect(),用例本身不用动。
|
||
*
|
||
* 约定:所有用例都对照后端 CloudBuilder.Security.Service/Authorization/PermissionContext.cs
|
||
* 的既有行为写,断言的是"前端与后端一致",不是"前端自己的偏好"。
|
||
*/
|
||
import type {
|
||
DependentRowLike,
|
||
PermissionInfoLike,
|
||
PermissionRowLike,
|
||
} from "./permission-kernel";
|
||
import {
|
||
actionsOf,
|
||
buildPermissionIndex,
|
||
can,
|
||
canAny,
|
||
canOperate,
|
||
grantedPermissions,
|
||
} from "./permission-kernel";
|
||
|
||
let failed = 0;
|
||
let total = 0;
|
||
|
||
/** 断言:失败不中断,好让一次跑完能看清全部问题 */
|
||
function check(label: string, actual: unknown, expected: unknown): void {
|
||
total++;
|
||
if (actual !== expected) {
|
||
failed++;
|
||
// 本项目的 no-console 只放行 warn/error(页面代码里不该打印),
|
||
// 这是个只在 node 下跑的校验脚本,就沿用放行的那一个
|
||
console.warn(
|
||
` FAIL ${label} => expected ${String(expected)}, got ${String(actual)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function row(
|
||
permission: string,
|
||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||
): PermissionRowLike {
|
||
return {
|
||
PermissionName: permission,
|
||
ViewIndc: flags.view,
|
||
CreateIndc: flags.create,
|
||
AmendIndc: flags.amend,
|
||
DeleteIndc: flags.delete,
|
||
};
|
||
}
|
||
|
||
function depRow(
|
||
permission: string,
|
||
dependent: string,
|
||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||
): DependentRowLike {
|
||
return { ...row(permission, flags), DependentDetailName: dependent };
|
||
}
|
||
|
||
function info(
|
||
permissions: PermissionRowLike[] = [],
|
||
dependents: DependentRowLike[] = [],
|
||
): PermissionInfoLike {
|
||
return { Permissions: permissions, PermissionDependents: dependents };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 1. view 必须检 ViewIndc
|
||
{
|
||
// view_indc='N' 而 create_indc='Y':后端 CanView 为 false。
|
||
// 旧前端只看"权限名在不在",会误放行,页面进来后列表接口一片 403。
|
||
const index = buildPermissionIndex(
|
||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||
);
|
||
check(
|
||
"view=N 时 canView 为 false(修复旧前端漏检)",
|
||
can(index, "view", "ActorEnquiry"),
|
||
false,
|
||
);
|
||
check(
|
||
"view=N 时 canNone 仍为 true(只要求挂过权限)",
|
||
can(index, "none", "ActorEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"create=Y 时 canCreate 为 true",
|
||
can(index, "create", "ActorEnquiry"),
|
||
true,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 2. 四种动作各自独立
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info([row("DmsFileEnquiry", { view: "Y" })]),
|
||
);
|
||
check("只给 view:view 通过", can(index, "view", "DmsFileEnquiry"), true);
|
||
check(
|
||
"只给 view:create 不通过",
|
||
can(index, "create", "DmsFileEnquiry"),
|
||
false,
|
||
);
|
||
check(
|
||
"只给 view:amend 不通过",
|
||
can(index, "amend", "DmsFileEnquiry"),
|
||
false,
|
||
);
|
||
check(
|
||
"只给 view:delete 不通过",
|
||
can(index, "delete", "DmsFileEnquiry"),
|
||
false,
|
||
);
|
||
|
||
const full = buildPermissionIndex(
|
||
info([
|
||
row("DmsFileEnquiry", {
|
||
view: "Y",
|
||
create: "Y",
|
||
amend: "Y",
|
||
delete: "Y",
|
||
}),
|
||
]),
|
||
);
|
||
check("四标全 Y:delete 通过", can(full, "delete", "DmsFileEnquiry"), true);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 3. 多用户组授权做 OR 合并
|
||
{
|
||
// 同一权限名由两个用户组分别授予 view 与 delete,合并后两者都应有
|
||
const index = buildPermissionIndex(
|
||
info([
|
||
row("FamilyGroupEnquiry", { view: "Y" }),
|
||
row("FamilyGroupEnquiry", { delete: "Y" }),
|
||
]),
|
||
);
|
||
check("多组合并:view 命中", can(index, "view", "FamilyGroupEnquiry"), true);
|
||
check(
|
||
"多组合并:delete 命中",
|
||
can(index, "delete", "FamilyGroupEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"多组合并:amend 仍未命中",
|
||
can(index, "amend", "FamilyGroupEnquiry"),
|
||
false,
|
||
);
|
||
const flags = actionsOf(index, "FamilyGroupEnquiry");
|
||
check(
|
||
"actionsOf 一次取全",
|
||
`${flags.view}/${flags.create}/${flags.amend}/${flags.delete}`,
|
||
"true/false/false/true",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 4. 数据级只查 dependents,不回退
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info(
|
||
[row("PublishPhotoEnquiry", { view: "Y" })],
|
||
[depRow("PublishPhotoEnquiry", "F1001", { view: "N" })],
|
||
),
|
||
);
|
||
check("功能级命中", can(index, "view", "PublishPhotoEnquiry"), true);
|
||
// 后端 CanView(permission, dependent) 只查 PermissionDependents;
|
||
// 前端若"数据级没查到就退回功能级",就比后端宽,会出现前端放行后端 403
|
||
check(
|
||
"数据级 N:不回退到功能级",
|
||
can(index, "view", "PublishPhotoEnquiry", "F1001"),
|
||
false,
|
||
);
|
||
check(
|
||
"数据级整条不存在:false",
|
||
can(index, "view", "PublishPhotoEnquiry", "F9999"),
|
||
false,
|
||
);
|
||
check(
|
||
"数据级 Y:命中",
|
||
can(
|
||
buildPermissionIndex(
|
||
info([], [depRow("PublishPhotoEnquiry", "F1", { view: "Y" })]),
|
||
),
|
||
"view",
|
||
"PublishPhotoEnquiry",
|
||
"F1",
|
||
),
|
||
true,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 5. 严格比较(与后端一致)
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info([
|
||
row("SecurityGroupEnquiry", { view: "y" }),
|
||
row(" security ", { view: "Y" }),
|
||
]),
|
||
);
|
||
check(
|
||
"小写 y 不算 YES(后端按 'Y' 精确比较)",
|
||
can(index, "view", "SecurityGroupEnquiry"),
|
||
false,
|
||
);
|
||
check("权限名区分大小写", can(index, "view", "securitygroupenquiry"), false);
|
||
check("权限名不做 trim", can(index, "view", "security"), false);
|
||
check("原样存在的名字可命中", can(index, "view", " security "), true);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 6. 档案缺失或损坏
|
||
{
|
||
const broken: PermissionInfoLike[] = [
|
||
{},
|
||
{ Permissions: null },
|
||
{ Permissions: undefined, PermissionDependents: undefined },
|
||
{ Permissions: [] },
|
||
{ Permissions: [null as unknown as PermissionRowLike] },
|
||
{ Permissions: [{ PermissionName: "" } as PermissionRowLike] },
|
||
{
|
||
PermissionDependents: [
|
||
{ PermissionName: "A", DependentDetailName: "" } as DependentRowLike,
|
||
],
|
||
},
|
||
];
|
||
broken.forEach((item, i) => {
|
||
const index = buildPermissionIndex(item);
|
||
check(
|
||
`损坏档案[${i}]:不抛异常且判定为 false`,
|
||
can(index, "view", "A"),
|
||
false,
|
||
);
|
||
check(`损坏档案[${i}]:none 也为 false`, can(index, "none", "A"), false);
|
||
});
|
||
check(
|
||
"传 null 得到空索引",
|
||
can(buildPermissionIndex(null), "none", "A"),
|
||
false,
|
||
);
|
||
check(
|
||
"不传参得到空索引",
|
||
grantedPermissions(buildPermissionIndex(), "none").length,
|
||
0,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 7. canAny 批量判定
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info([row("PreviewAudio", { view: "Y" })]),
|
||
);
|
||
check("数组任一命中", canAny(index, "view", ["Nope", "PreviewAudio"]), true);
|
||
check("数组全不命中", canAny(index, "view", ["Nope", "Nothing"]), false);
|
||
check("空数组为 false(而非 undefined)", canAny(index, "view", []), false);
|
||
check(
|
||
"数组里的空串被跳过",
|
||
canAny(index, "view", ["", "PreviewAudio"]),
|
||
true,
|
||
);
|
||
check("单串仍可用", canAny(index, "view", "PreviewAudio"), true);
|
||
check(
|
||
"批量判定带 dependent",
|
||
canAny(index, "view", ["PreviewAudio"], "F1"),
|
||
false,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 8. 未登记动作与未知权限
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info([row("ActorEnquiry", { view: "Y" })]),
|
||
);
|
||
check("未授予的权限名:false", can(index, "view", "NeverGranted"), false);
|
||
const flags = actionsOf(index, "NeverGranted");
|
||
check(
|
||
"未授予权限的 actionsOf 返回全 false 而非 undefined",
|
||
`${flags.none}/${flags.view}`,
|
||
"false/false",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 9. 已授权清单
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info([
|
||
row("A_Enquiry", { view: "Y" }),
|
||
row("B_Enquiry", { view: "N", create: "Y" }),
|
||
row("C_Enquiry", { view: "Y", delete: "Y" }),
|
||
]),
|
||
);
|
||
check(
|
||
"none 清单含全部挂过的名字",
|
||
grantedPermissions(index, "none").join(","),
|
||
"A_Enquiry,B_Enquiry,C_Enquiry",
|
||
);
|
||
check(
|
||
"view 清单只含 Y",
|
||
grantedPermissions(index, "view").join(","),
|
||
"A_Enquiry,C_Enquiry",
|
||
);
|
||
check(
|
||
"create 清单",
|
||
grantedPermissions(index, "create").join(","),
|
||
"B_Enquiry",
|
||
);
|
||
check(
|
||
"delete 清单",
|
||
grantedPermissions(index, "delete").join(","),
|
||
"C_Enquiry",
|
||
);
|
||
check("amend 清单为空", grantedPermissions(index, "amend").length, 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 10. 操作型判定(对齐后端 PermissionHandler 的 switch)
|
||
{
|
||
const index = buildPermissionIndex(
|
||
info(
|
||
[
|
||
row("PublishPhotoEnquiry", {
|
||
view: "Y",
|
||
create: "Y",
|
||
amend: "Y",
|
||
delete: "Y",
|
||
}),
|
||
],
|
||
// Submit/Approval 这类操作在后端是"数据级授权行",靠 DependentDetailName 命中
|
||
[depRow("PublishPhotoEnquiry", "Submit", { view: "N" })],
|
||
),
|
||
);
|
||
check("Find -> view", canOperate(index, "Find", "PublishPhotoEnquiry"), true);
|
||
check(
|
||
"Print -> view",
|
||
canOperate(index, "Print", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"Insert -> create",
|
||
canOperate(index, "Insert", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"Update -> amend",
|
||
canOperate(index, "Update", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"Delete -> delete",
|
||
canOperate(index, "Delete", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"None 只要求挂过",
|
||
canOperate(index, "None", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"Submit 走数据级,命中",
|
||
canOperate(index, "Submit", "PublishPhotoEnquiry"),
|
||
true,
|
||
);
|
||
check(
|
||
"Approval 未挂 → false(即使四个 Indc 全 Y)",
|
||
canOperate(index, "Approval", "PublishPhotoEnquiry"),
|
||
false,
|
||
);
|
||
|
||
// view_indc='N' 时 Find 必须为 false:这条正是旧前端会误判成 true 的场景
|
||
const noView = buildPermissionIndex(
|
||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||
);
|
||
check(
|
||
"view=N 时 Find 为 false",
|
||
canOperate(noView, "Find", "ActorEnquiry"),
|
||
false,
|
||
);
|
||
check(
|
||
"view=N 时 Insert 仍为 true",
|
||
canOperate(noView, "Insert", "ActorEnquiry"),
|
||
true,
|
||
);
|
||
}
|
||
|
||
console.warn(
|
||
failed === 0
|
||
? `permission-kernel: ${total} assertions passed`
|
||
: `permission-kernel: ${failed}/${total} assertions FAILED`,
|
||
);
|
||
if (failed > 0) {
|
||
// 让非零退出可被脚本捕获,避免"跑了但没人看输出"
|
||
throw new Error("permission-kernel assertions failed");
|
||
}
|