feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* PluginBoundary 插件级错误边界 + 流式 Suspense 测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - 正常渲染 children
|
||||
* - 子组件抛错时显示 fallback(含 pluginId 和错误消息)
|
||||
* - 重试按钮触发 reset
|
||||
* - 5 种骨架变体渲染(card/list/chart/stats/table)
|
||||
* - 错误自动上报 useErrorReport
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L3 插件级)
|
||||
*/
|
||||
|
||||
// mock useErrorReport
|
||||
const reportErrorMock = vi.fn();
|
||||
vi.mock("@edu/hooks", () => ({
|
||||
useErrorReport: () => reportErrorMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
PluginBoundary,
|
||||
PluginSkeleton,
|
||||
} from "@/shared/components/plugin-boundary";
|
||||
|
||||
// 工具:制造抛错组件
|
||||
function ThrowOnRender({ message }: { message: string }): ReactNode {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function GoodComponent(): ReactNode {
|
||||
return <div data-testid="good">正常内容</div>;
|
||||
}
|
||||
|
||||
describe("PluginBoundary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清除 console.error 噪音(React ErrorBoundary 会打 console.error)
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("正常渲染 children", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="test-plugin">
|
||||
<GoodComponent />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId("good")).toBeTruthy();
|
||||
expect(screen.queryByText("插件加载失败")).toBeNull();
|
||||
});
|
||||
|
||||
it("子组件抛错时显示 fallback", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="bad-plugin">
|
||||
<ThrowOnRender message="渲染崩溃" />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
expect(screen.getByText(/bad-plugin: 渲染崩溃/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("错误触发 useErrorReport 上报", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="report-plugin">
|
||||
<ThrowOnRender message="需上报的错误" />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
expect(reportErrorMock).toHaveBeenCalledTimes(1);
|
||||
const [error, options] = reportErrorMock.mock.calls[0]!;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("需上报的错误");
|
||||
expect(options).toEqual({
|
||||
pluginId: "report-plugin",
|
||||
level: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("重试按钮触发 reset 并重新渲染", () => {
|
||||
let shouldThrow = true;
|
||||
function FlakyComponent(): ReactNode {
|
||||
if (shouldThrow) throw new Error("偶发错误");
|
||||
return <div data-testid="recovered">恢复</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<PluginBoundary pluginId="flaky-plugin">
|
||||
<FlakyComponent />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
|
||||
// 切换为不抛错
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByText("重试"));
|
||||
|
||||
expect(screen.getByTestId("recovered")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginSkeleton 骨架变体", () => {
|
||||
it("card 变体(默认)渲染骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="card" />);
|
||||
expect(container.querySelector('[role="status"]')).toBeTruthy();
|
||||
expect(container.querySelector('[aria-label="加载中"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list 变体渲染 4 行骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="list" />);
|
||||
const skeletons = container.querySelectorAll('[class*="h-12"]');
|
||||
expect(skeletons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("chart 变体渲染柱状骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="chart" />);
|
||||
const bars = container.querySelectorAll('[class*="flex-1"]');
|
||||
expect(bars.length).toBe(7);
|
||||
});
|
||||
|
||||
it("stats 变体渲染 3 列统计骨架", () => {
|
||||
const { container } = render(<PluginSkeleton variant="stats" />);
|
||||
const cols = container.querySelectorAll(".flex-1");
|
||||
expect(cols.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("table 变体渲染表头 + 4 行", () => {
|
||||
const { container } = render(<PluginSkeleton variant="table" />);
|
||||
const rows = container.querySelectorAll('[class*="h-10"]');
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
it("所有变体都有 aria-live=polite 和 role=status", () => {
|
||||
for (const variant of [
|
||||
"card",
|
||||
"list",
|
||||
"chart",
|
||||
"stats",
|
||||
"table",
|
||||
] as const) {
|
||||
const { container } = render(<PluginSkeleton variant={variant} />);
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).toBeTruthy();
|
||||
expect(status?.getAttribute("aria-live")).toBe("polite");
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
152
apps/portal-shell/src/shared/lib/__tests__/notify.test.ts
Normal file
152
apps/portal-shell/src/shared/lib/__tests__/notify.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* notify 统一 Toast 封装测试
|
||||
*
|
||||
* 覆盖:success/error/warning/info/promise/loading/message/dismiss 方法委托
|
||||
* error 默认 duration=6000
|
||||
* promise 透传原 Promise(便于链式调用)
|
||||
* 关联:portal-shell README v2.0 §5.4
|
||||
*/
|
||||
|
||||
// mock sonner 模块:toast 既是可调用函数,又有方法(success/error/etc.)
|
||||
// vi.hoisted 确保 mock 变量在 vi.mock 提升前初始化
|
||||
const { toastMock } = vi.hoisted(() => {
|
||||
const fn = vi.fn() as unknown as {
|
||||
(): void;
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
warning: ReturnType<typeof vi.fn>;
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
promise: ReturnType<typeof vi.fn>;
|
||||
loading: ReturnType<typeof vi.fn>;
|
||||
message: ReturnType<typeof vi.fn>;
|
||||
dismiss: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
fn.success = vi.fn();
|
||||
fn.error = vi.fn();
|
||||
fn.warning = vi.fn();
|
||||
fn.info = vi.fn();
|
||||
fn.promise = vi.fn();
|
||||
fn.loading = vi.fn();
|
||||
fn.message = vi.fn();
|
||||
fn.dismiss = vi.fn();
|
||||
return { toastMock: fn };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: toastMock,
|
||||
ExternalToast: {},
|
||||
}));
|
||||
|
||||
// 导入被测模块(在 mock 之后)
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { toast } from "sonner";
|
||||
|
||||
describe("notify", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("success", () => {
|
||||
it("委托 toast.success", () => {
|
||||
notify.success("保存成功");
|
||||
expect(toast.success).toHaveBeenCalledWith("保存成功", undefined);
|
||||
});
|
||||
|
||||
it("传递 options", () => {
|
||||
notify.success("保存成功", { duration: 3000 });
|
||||
expect(toast.success).toHaveBeenCalledWith("保存成功", {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
it("委托 toast.error,默认 duration=6000", () => {
|
||||
notify.error("网络错误");
|
||||
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||
duration: 6000,
|
||||
});
|
||||
});
|
||||
|
||||
it("options 可覆盖默认 duration", () => {
|
||||
notify.error("网络错误", { duration: 2000 });
|
||||
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it("options 可追加 description 等字段", () => {
|
||||
notify.error("网络错误", { description: "请检查网络连接" });
|
||||
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||
duration: 6000,
|
||||
description: "请检查网络连接",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("warning", () => {
|
||||
it("委托 toast.warning", () => {
|
||||
notify.warning("警告信息");
|
||||
expect(toast.warning).toHaveBeenCalledWith("警告信息", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("info", () => {
|
||||
it("委托 toast.info", () => {
|
||||
notify.info("提示信息");
|
||||
expect(toast.info).toHaveBeenCalledWith("提示信息", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promise", () => {
|
||||
it("调用 toast.promise 并透传原 Promise", async () => {
|
||||
const originalPromise = Promise.resolve("data");
|
||||
const options = {
|
||||
loading: "加载中",
|
||||
success: "成功",
|
||||
error: "失败",
|
||||
};
|
||||
const result = notify.promise(originalPromise, options);
|
||||
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
|
||||
expect(result).toBe(originalPromise);
|
||||
await expect(result).resolves.toBe("data");
|
||||
});
|
||||
|
||||
it("支持函数式 success/error 回调", async () => {
|
||||
const originalPromise = Promise.reject(new Error("fail"));
|
||||
const options = {
|
||||
loading: "加载中",
|
||||
success: (data: unknown) => `成功: ${data}`,
|
||||
error: (err: unknown) => `失败: ${(err as Error).message}`,
|
||||
};
|
||||
const result = notify.promise(originalPromise, options);
|
||||
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
|
||||
await expect(result).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading", () => {
|
||||
it("委托 toast.loading 并返回 toast id", () => {
|
||||
vi.mocked(toast.loading).mockReturnValue("toast-1");
|
||||
const id = notify.loading("加载中");
|
||||
expect(toast.loading).toHaveBeenCalledWith("加载中", undefined);
|
||||
expect(id).toBe("toast-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message", () => {
|
||||
it("委托 toast 作为函数调用", () => {
|
||||
notify.message("自定义消息");
|
||||
expect(toast).toHaveBeenCalledWith("自定义消息", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismiss", () => {
|
||||
it("委托 toast.dismiss 关闭所有", () => {
|
||||
notify.dismiss();
|
||||
expect(toast.dismiss).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
decodePermissionsBitmap,
|
||||
hasPermissionInBitmap,
|
||||
hasAnyPermissionInBitmap,
|
||||
hasAllPermissionsInBitmap,
|
||||
isValidPermission,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* 权限位图编解码测试
|
||||
*
|
||||
* 覆盖:编码/解码互逆性、空输入、未知权限、单点检查、批量检查、无效字符
|
||||
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||
*/
|
||||
describe("permission-bitmap", () => {
|
||||
describe("encodePermissionsBitmap", () => {
|
||||
it("空数组编码为 0", () => {
|
||||
expect(encodePermissionsBitmap([])).toBe("0");
|
||||
});
|
||||
|
||||
it("单个权限点编码正确", () => {
|
||||
// 第一个权限点 DASHBOARD_ADMIN_READ = bit 0 → 1 → "1"
|
||||
expect(encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"])).toBe("1");
|
||||
// 第二个权限点 DASHBOARD_TEACHER_READ = bit 1 → 2 → "2"
|
||||
expect(encodePermissionsBitmap(["DASHBOARD_TEACHER_READ"])).toBe("2");
|
||||
});
|
||||
|
||||
it("多个权限点合并编码", () => {
|
||||
// bit 0 + bit 1 = 3 → "3"
|
||||
const encoded = encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"DASHBOARD_TEACHER_READ",
|
||||
]);
|
||||
expect(encoded).toBe("3");
|
||||
});
|
||||
|
||||
it("未知权限静默忽略", () => {
|
||||
const valid = encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"]);
|
||||
const withUnknown = encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"UNKNOWN_PERMISSION_XYZ",
|
||||
]);
|
||||
expect(withUnknown).toBe(valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodePermissionsBitmap", () => {
|
||||
it("编码解码互逆", () => {
|
||||
const perms = [
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"USER_MANAGE",
|
||||
"ROLE_READ",
|
||||
"EXAM_CREATE",
|
||||
];
|
||||
const encoded = encodePermissionsBitmap(perms);
|
||||
const decoded = decodePermissionsBitmap(encoded);
|
||||
expect(decoded.sort()).toEqual([...perms].sort());
|
||||
});
|
||||
|
||||
it("空字符串返回空数组", () => {
|
||||
expect(decodePermissionsBitmap("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("无效字符返回空数组", () => {
|
||||
expect(decodePermissionsBitmap("abc!def")).toEqual([]);
|
||||
expect(decodePermissionsBitmap("abc def")).toEqual([]);
|
||||
});
|
||||
|
||||
it("0 解码为空数组", () => {
|
||||
expect(decodePermissionsBitmap("0")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPermissionInBitmap", () => {
|
||||
it("拥有的权限返回 true", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(hasPermissionInBitmap(encoded, "USER_MANAGE")).toBe(true);
|
||||
});
|
||||
|
||||
it("未拥有的权限返回 false", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(hasPermissionInBitmap(encoded, "ROLE_READ")).toBe(false);
|
||||
});
|
||||
|
||||
it("未知权限点返回 false", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(hasPermissionInBitmap(encoded, "UNKNOWN_PERM")).toBe(false);
|
||||
});
|
||||
|
||||
it("无效位图返回 false", () => {
|
||||
expect(hasPermissionInBitmap("invalid!", "USER_MANAGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("空位图返回 false", () => {
|
||||
expect(hasPermissionInBitmap("", "USER_MANAGE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAnyPermissionInBitmap", () => {
|
||||
it("任一权限满足即 true", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(
|
||||
hasAnyPermissionInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("全部不满足即 false", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(
|
||||
hasAnyPermissionInBitmap(encoded, ["ROLE_READ", "EXAM_CREATE"]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("空数组返回 false", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(hasAnyPermissionInBitmap(encoded, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAllPermissionsInBitmap", () => {
|
||||
it("全部权限满足即 true", () => {
|
||||
const encoded = encodePermissionsBitmap([
|
||||
"USER_MANAGE",
|
||||
"ROLE_READ",
|
||||
"EXAM_CREATE",
|
||||
]);
|
||||
expect(
|
||||
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("部分不满足即 false", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(
|
||||
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("空数组返回 true(vacuous truth)", () => {
|
||||
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||
expect(hasAllPermissionsInBitmap(encoded, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPermission", () => {
|
||||
it("已知权限点返回 true", () => {
|
||||
expect(isValidPermission("DASHBOARD_ADMIN_READ")).toBe(true);
|
||||
expect(isValidPermission("USER_MANAGE")).toBe(true);
|
||||
});
|
||||
|
||||
it("未知权限点返回 false", () => {
|
||||
expect(isValidPermission("UNKNOWN_PERM")).toBe(false);
|
||||
expect(isValidPermission("")).toBe(false);
|
||||
});
|
||||
|
||||
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
|
||||
for (const perm of PERMISSION_BITMAP_ORDER) {
|
||||
expect(isValidPermission(perm)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("全量编解码压力测试", () => {
|
||||
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
|
||||
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||
const encoded = encodePermissionsBitmap(allPerms);
|
||||
const decoded = decodePermissionsBitmap(encoded);
|
||||
expect(decoded.sort()).toEqual([...allPerms].sort());
|
||||
});
|
||||
|
||||
it("全部权限点位图长度合理(base36 < 20 字符)", () => {
|
||||
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||
const encoded = encodePermissionsBitmap(allPerms);
|
||||
// 67 bit → base36 约 14 字符
|
||||
expect(encoded.length).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
checkRoutePermission,
|
||||
batchCheckRoutePermission,
|
||||
validateRoutePermissionConfigs,
|
||||
EXACT_ROUTE_PERMISSIONS,
|
||||
PREFIX_ROUTE_PERMISSIONS,
|
||||
DASHBOARD_ROUTE_PERMISSIONS,
|
||||
} from "@/shared/lib/route-permissions";
|
||||
import { encodePermissionsBitmap } from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* 路由权限配置测试
|
||||
*
|
||||
* 覆盖:4 张表优先级匹配、L1 角色门禁、L2 权限点门禁(AND/OR)、公共路由放行、批量检查、配置合法性
|
||||
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||
*/
|
||||
|
||||
// 测试用 bitmap
|
||||
const ADMIN_BITMAP = encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"USER_MANAGE",
|
||||
"ROLE_MANAGE",
|
||||
"PERMISSION_MANAGE",
|
||||
"AUDIT_LOG_READ",
|
||||
"SCHOOL_MANAGE",
|
||||
"PLUGIN_REGISTRY_MANAGE",
|
||||
"DASHBOARD_READ",
|
||||
]);
|
||||
|
||||
const TEACHER_BITMAP = encodePermissionsBitmap([
|
||||
"DASHBOARD_TEACHER_READ",
|
||||
"DASHBOARD_READ",
|
||||
"LESSON_PLAN_READ",
|
||||
"QUESTION_READ",
|
||||
"TEXTBOOK_READ",
|
||||
]);
|
||||
|
||||
const STUDENT_BITMAP = encodePermissionsBitmap([
|
||||
"DASHBOARD_STUDENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
"ERROR_BOOK_READ",
|
||||
"LEARNING_PATH_READ",
|
||||
"AI_TUTOR_USE",
|
||||
]);
|
||||
|
||||
const PARENT_BITMAP = encodePermissionsBitmap([
|
||||
"DASHBOARD_PARENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
"GRADE_READ_CHILD",
|
||||
"LEAVE_APPROVAL_MANAGE",
|
||||
]);
|
||||
|
||||
describe("route-permissions", () => {
|
||||
describe("EXACT 路由匹配(最高优先级)", () => {
|
||||
it("admin 用户访问 /shell/admin/users 且有 USER_MANAGE 权限 → 放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/users",
|
||||
ADMIN_BITMAP,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchedPath).toBe("/shell/admin/users");
|
||||
});
|
||||
|
||||
it("teacher 角色访问 admin 路由 → 拒绝(missing_role)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/users",
|
||||
ADMIN_BITMAP,
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("admin 角色但缺少 USER_MANAGE 权限 → 拒绝(missing_permission)", () => {
|
||||
const noUserManage = encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"DASHBOARD_READ",
|
||||
]);
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/users",
|
||||
noUserManage,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_permission");
|
||||
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
|
||||
});
|
||||
|
||||
it("anyOfPermissions OR 语义:满足任一即放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/invitation-codes",
|
||||
encodePermissionsBitmap(["INVITATION_CODE_CREATE"]),
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("anyOfPermissions OR 语义:全不满足即拒绝", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/invitation-codes",
|
||||
encodePermissionsBitmap([]),
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_permission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PREFIX 路由匹配(中优先级)", () => {
|
||||
it("/shell/admin/ 子路由要求 admin 角色", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/unknown-page",
|
||||
ADMIN_BITMAP,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchedPath).toBe("/shell/admin/");
|
||||
});
|
||||
|
||||
it("/shell/admin/ 子路由拒绝非 admin 角色", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/unknown-page",
|
||||
TEACHER_BITMAP,
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("/shell/teacher/exams/ 前缀匹配", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher/exams/123",
|
||||
TEACHER_BITMAP,
|
||||
"teacher",
|
||||
);
|
||||
// TEACHER_BITMAP 没有 EXAM_READ,需要检查 anyOf
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_permission");
|
||||
});
|
||||
|
||||
it("前缀匹配不误匹配(/shell/admin 不应匹配 /shell/admin-users)", () => {
|
||||
// /shell/admin-users 不匹配 /shell/admin/ 前缀(因为前缀以 / 结尾)
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin-users",
|
||||
ADMIN_BITMAP,
|
||||
"admin",
|
||||
);
|
||||
// 不匹配任何前缀,应走到仪表盘或公共路由
|
||||
expect(result.matchedPath).not.toBe("/shell/admin/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DASHBOARD 路由匹配(低优先级)", () => {
|
||||
it("admin 仪表盘根路径", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin",
|
||||
ADMIN_BITMAP,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchedPath).toBe("/shell/admin");
|
||||
});
|
||||
|
||||
it("teacher 仪表盘根路径", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher",
|
||||
TEACHER_BITMAP,
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("student 仪表盘根路径", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/student",
|
||||
STUDENT_BITMAP,
|
||||
"student",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("parent 仪表盘根路径", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/parent",
|
||||
PARENT_BITMAP,
|
||||
"parent",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("角色不匹配 → 拒绝", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin",
|
||||
TEACHER_BITMAP,
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("通用仪表盘 /shell 只需 DASHBOARD_READ", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell",
|
||||
encodePermissionsBitmap(["DASHBOARD_READ"]),
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API 路由匹配", () => {
|
||||
it("/api/log 所有登录用户可访问", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/api/log",
|
||||
encodePermissionsBitmap([]),
|
||||
"student",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("/api/healthz 公开访问", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/api/healthz",
|
||||
encodePermissionsBitmap([]),
|
||||
"student",
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("未配置的 API 路由默认拒绝", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/api/unknown",
|
||||
ADMIN_BITMAP,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("no_config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("公共路由默认放行", () => {
|
||||
it("/ 根路径放行", () => {
|
||||
const result = checkRoutePermission("/", "", "student");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("/login 放行", () => {
|
||||
const result = checkRoutePermission("/login", "", "student");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("/shell/forbidden 放行", () => {
|
||||
const result = checkRoutePermission("/shell/forbidden", "", "student");
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchCheckRoutePermission", () => {
|
||||
it("批量检查多个路径", () => {
|
||||
const paths = [
|
||||
"/shell/admin/users",
|
||||
"/shell/teacher",
|
||||
"/shell/student",
|
||||
"/api/log",
|
||||
];
|
||||
const result = batchCheckRoutePermission(paths, ADMIN_BITMAP, "admin");
|
||||
expect(result["/shell/admin/users"]).toBe(true);
|
||||
expect(result["/shell/teacher"]).toBe(false); // admin 没有 DASHBOARD_TEACHER_READ
|
||||
expect(result["/shell/student"]).toBe(false);
|
||||
expect(result["/api/log"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRoutePermissionConfigs", () => {
|
||||
it("所有配置的权限点应合法", () => {
|
||||
const invalid = validateRoutePermissionConfigs();
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("配置表非空校验", () => {
|
||||
it("EXACT_ROUTE_PERMISSIONS 非空", () => {
|
||||
expect(Object.keys(EXACT_ROUTE_PERMISSIONS).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("PREFIX_ROUTE_PERMISSIONS 非空", () => {
|
||||
expect(PREFIX_ROUTE_PERMISSIONS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("DASHBOARD_ROUTE_PERMISSIONS 包含 4 个角色 + 通用", () => {
|
||||
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/admin"]).toBeDefined();
|
||||
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/teacher"]).toBeDefined();
|
||||
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/student"]).toBeDefined();
|
||||
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/parent"]).toBeDefined();
|
||||
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useErrorReport } from "@edu/hooks";
|
||||
|
||||
/**
|
||||
* useErrorReport 错误上报 Hook 测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - sendBeacon 上报路径和 payload 结构
|
||||
* - fetch keepalive 降级
|
||||
* - sessionStorage 节流(同 digest 1 分钟内只上报一次)
|
||||
* - userId 从 localStorage 读取
|
||||
* - 上报失败静默降级
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
|
||||
// mock navigator.sendBeacon
|
||||
const sendBeaconMock = vi.fn().mockReturnValue(true);
|
||||
// mock fetch
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response());
|
||||
|
||||
// 存储最近一次 sendBeacon 的 body 字符串(绕过 jsdom Blob 读取限制)
|
||||
let lastBeaconBody: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
lastBeaconBody = undefined;
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
|
||||
// mock Blob 构造函数,捕获 body 字符串
|
||||
vi.stubGlobal(
|
||||
"Blob",
|
||||
vi.fn((parts: BlobPart[], options?: { type?: string }) => {
|
||||
lastBeaconBody = String(parts[0]);
|
||||
return { type: options?.type ?? "", size: lastBeaconBody.length };
|
||||
}),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: {
|
||||
sendBeacon: sendBeaconMock,
|
||||
userAgent: "test-agent",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
/** 从最近一次 sendBeacon 调用中提取 payload */
|
||||
function getLastPayload(): Record<string, unknown> {
|
||||
if (!lastBeaconBody) throw new Error("No sendBeacon body captured");
|
||||
return JSON.parse(lastBeaconBody);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("useErrorReport", () => {
|
||||
it("通过 sendBeacon 上报到 /api/log", () => {
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
const error = new Error("测试错误");
|
||||
|
||||
act(() => {
|
||||
result.current(error);
|
||||
});
|
||||
|
||||
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||
const [endpoint, blob] = sendBeaconMock.mock.calls[0]!;
|
||||
expect(endpoint).toBe("/api/log");
|
||||
expect(blob).toBeTruthy();
|
||||
expect(blob.type).toBe("application/json");
|
||||
});
|
||||
|
||||
it("payload 包含必需字段", () => {
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
const error = new Error("测试错误");
|
||||
error.stack = "Error: 测试错误\n at test";
|
||||
|
||||
act(() => {
|
||||
result.current(error);
|
||||
});
|
||||
|
||||
const payload = getLastPayload();
|
||||
expect(payload.level).toBe("error");
|
||||
expect(payload.message).toBe("测试错误");
|
||||
expect(payload.stack).toBe("Error: 测试错误\n at test");
|
||||
expect(payload.path).toBe("/");
|
||||
expect(payload.userAgent).toBe("test-agent");
|
||||
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
expect(payload.digest).toBeTruthy();
|
||||
});
|
||||
|
||||
it("支持 pluginId 和 level 选项", () => {
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
const error = new Error("插件错误");
|
||||
|
||||
act(() => {
|
||||
result.current(error, {
|
||||
pluginId: "grades-widget",
|
||||
level: "warning",
|
||||
context: { widgetId: "g1" },
|
||||
});
|
||||
});
|
||||
|
||||
const payload = getLastPayload();
|
||||
expect(payload.pluginId).toBe("grades-widget");
|
||||
expect(payload.level).toBe("warning");
|
||||
expect(payload.context).toEqual({ widgetId: "g1" });
|
||||
});
|
||||
|
||||
it("从 localStorage 读取 userId", () => {
|
||||
localStorage.setItem("edu_user_id", "user-123");
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
|
||||
act(() => {
|
||||
result.current(new Error("测试"));
|
||||
});
|
||||
|
||||
const payload = getLastPayload();
|
||||
expect(payload.userId).toBe("user-123");
|
||||
});
|
||||
|
||||
it("节流:同 digest 1 分钟内只上报一次", () => {
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
const error = new Error("重复错误");
|
||||
|
||||
act(() => {
|
||||
result.current(error);
|
||||
});
|
||||
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 第二次相同错误应被节流
|
||||
act(() => {
|
||||
result.current(error);
|
||||
});
|
||||
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("不同 digest 的错误不互相影响", () => {
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
|
||||
act(() => {
|
||||
result.current(new Error("错误A"));
|
||||
});
|
||||
act(() => {
|
||||
result.current(new Error("错误B"));
|
||||
});
|
||||
expect(sendBeaconMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sendBeacon 返回 false 时降级到 fetch keepalive", () => {
|
||||
sendBeaconMock.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
|
||||
act(() => {
|
||||
result.current(new Error("降级测试"));
|
||||
});
|
||||
|
||||
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [endpoint, init] = fetchMock.mock.calls[0]!;
|
||||
expect(endpoint).toBe("/api/log");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.keepalive).toBe(true);
|
||||
expect(init.credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("sendBeacon 抛异常时降级到 fetch", () => {
|
||||
sendBeaconMock.mockImplementation(() => {
|
||||
throw new Error("sendBeacon 不可用");
|
||||
});
|
||||
const { result } = renderHook(() => useErrorReport());
|
||||
|
||||
act(() => {
|
||||
result.current(new Error("降级测试"));
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reportError 是稳定的 useCallback(空依赖)", () => {
|
||||
const { result, rerender } = renderHook(() => useErrorReport());
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user