diff --git a/apps/portal-shell/README.md b/apps/portal-shell/README.md index 53bb2f5..1966269 100644 --- a/apps/portal-shell/README.md +++ b/apps/portal-shell/README.md @@ -2,7 +2,7 @@ > 版本:2.0 > 日期:2026-07-17 -> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功) +> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过) > 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘) > 关联文档: > @@ -1146,52 +1146,58 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为 - [x] `pnpm run lint` + `pnpm run typecheck` 零错误(v2.0:含 2 个 auto-generated 文件警告,可忽略) - [x] `pnpm run build` 通过(v2.0:Next.js 16 Turbopack,6 路由生成成功:`/` / `/_not-found` / `/api/health` / `/api/log` / `/api/ready` / `/shell/[[...route]]`) -- [x] `pnpm run test` 95/95 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例) +- [x] `pnpm run test` 206/206 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例 + v2.0 新增组件单元测试 81 用例 + v2.0 E2E 测试 30 用例) - [x] 0 处 widget 内联 gql 字面量(强制,arch:scan 违规检测) - [x] 所有新代码遵守 shadcn 标准令牌(v2.0:ESLint 强制,无硬编码颜色/字体/任意值) +- [x] 31 个 widget 旧纸感令牌全部迁移到 shadcn 标准(P1:1104 次替换,arch:scan 零违规) - [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制 - [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission - [x] arch.db 更新,004 文档同步 - [x] **v2.0 README 同步**:本文件 v2.0,004 同步更新 ADR-044/045/046/047 - [x] **v2.0 流式渲染验证**:首屏 HTML 直出骨架,Config resolve 后流式注入(本地 Docker 验证) +- [x] **v2.0 P2 单元测试**:5 文件 81 用例(权限位图 24 + 路由权限 26 + notify 12 + useErrorReport 9 + PluginBoundary 10) +- [x] **v2.0 P3 生产端点**:api-gateway POST /api/v1/log + useErrorReport 环境感知端点切换 +- [x] **v2.0 P4 E2E 测试**:3 文件 30 用例(流式渲染 4 + 三级错误边界 6 + 三层安全边界 20) - [ ] Shell 首屏 LCP < 2s(需真实环境压测验证) - [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证) -- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量,admin domain 仅 4 用例待补,插件组件测试待补,v2.0 新增组件测试待补) -- [ ] E2E 测试(tests/e2e/portal-shell.spec.ts,待补,含 v2.0 流式渲染 + 三级错误边界场景) +- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量 + v2.0 新增组件全量,admin domain 仅 4 用例待补,插件组件渲染测试待补) +- [ ] E2E 测试(tests/e2e/ 真实浏览器场景,含 admin 改配置 → 用户刷新生效、apollo-router 深度限制) - [ ] 视觉回归测试(5 种 layout 截图,待补,含 v2.0 shadcn 标准化对比) - [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env -- [ ] 31 个 widget 旧纸感令牌批量迁移到 shadcn 标准(P1 阶段,arch:scan 违规检测) ### 11.3 测试矩阵 -| 测试类型 | 范围 | 文件 | 状态 | -| -------- | --------------------------------------------- | ---------------------------------------------------------- | ----------------- | -| 单元测试 | PluginLifecycle 纯函数 | `src/shell/__tests__/PluginLifecycle.test.ts` | ✅ 12 用例 | -| 单元测试 | Registry 插件注册 | `src/shell/__tests__/Registry.test.ts` | ✅ 6 用例 | -| 单元测试 | plugin-context URL 上下文 | `src/lib/__tests__/plugin-context.test.ts` | ✅ 12 用例 | -| 单元测试 | lib/api universal domain | `src/lib/api/__tests__/universal.test.ts` | ✅ 6 用例 | -| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例 | -| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例 | -| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例 | -| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例 | -| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例 | -| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(待补) | -| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例 | -| 单元测试 | 权限位图 base36 编解码(v2.0) | `packages/shared-ts/__tests__/permission-bitmap.test.ts` | 🚧 待补 | -| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | `src/shared/lib/__tests__/route-permissions.test.ts` | 🚧 待补 | -| 单元测试 | notify 统一封装(v2.0) | `src/shared/lib/__tests__/notify.test.ts` | 🚧 待补 | -| 单元测试 | useErrorReport 节流逻辑(v2.0) | `packages/hooks/__tests__/use-error-report.test.ts` | 🚧 待补 | -| 单元测试 | PluginBoundary 三件套(v2.0) | `src/shared/components/__tests__/plugin-boundary.test.tsx` | 🚧 待补 | -| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 | -| 单元测试 | 各插件组件渲染 | 待补 | 🚧 | -| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | `tests/e2e/portal-shell.spec.ts` | ⏳ | -| E2E | admin 改配置 → 用户刷新生效 | `tests/e2e/plugin-config.spec.ts` | ⏳ | -| E2E | apollo-router 拒绝 11 层嵌套查询 | `tests/e2e/graphql-depth-limit.spec.ts` | ⏳ | -| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳ | -| E2E | 流式渲染 + 三级错误边界(v2.0) | `tests/e2e/streaming-and-error-boundary.spec.ts` | ⏳(v2.0) | -| E2E | 三层安全边界 + 权限位图(v2.0) | `tests/e2e/security-boundary.spec.ts` | ⏳(v2.0) | -| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ | -| 视觉回归 | shadcn 标准化对比(v2.0) | `tests/visual/shadcn-migration.spec.ts` | ⏳(v2.0) | +| 测试类型 | 范围 | 文件 | 状态 | +| -------- | --------------------------------------------------- | ---------------------------------------------------------- | ----------------- | +| 单元测试 | PluginLifecycle 纯函数 | `src/shell/__tests__/PluginLifecycle.test.ts` | ✅ 12 用例 | +| 单元测试 | Registry 插件注册 | `src/shell/__tests__/Registry.test.ts` | ✅ 6 用例 | +| 单元测试 | plugin-context URL 上下文 | `src/lib/__tests__/plugin-context.test.ts` | ✅ 12 用例 | +| 单元测试 | lib/api universal domain | `src/lib/api/__tests__/universal.test.ts` | ✅ 6 用例 | +| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例 | +| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例 | +| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例 | +| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例 | +| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例 | +| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(待补) | +| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例 | +| 单元测试 | 权限位图 base36 编解码(v2.0) | `src/shared/lib/__tests__/permission-bitmap.test.ts` | ✅ 24 用例 | +| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | `src/shared/lib/__tests__/route-permissions.test.ts` | ✅ 26 用例 | +| 单元测试 | notify 统一封装(v2.0) | `src/shared/lib/__tests__/notify.test.ts` | ✅ 12 用例 | +| 单元测试 | useErrorReport 节流逻辑(v2.0) | `src/shared/lib/__tests__/use-error-report.test.ts` | ✅ 9 用例 | +| 单元测试 | PluginBoundary 三件套(v2.0) | `src/shared/components/__tests__/plugin-boundary.test.tsx` | ✅ 10 用例 | +| E2E | 流式渲染(React 19 use() + Suspense)(v2.0) | `src/__tests__/e2e/streaming.test.tsx` | ✅ 4 用例 | +| E2E | 三级错误边界层级(v2.0) | `src/__tests__/e2e/error-boundaries.test.tsx` | ✅ 6 用例 | +| E2E | 三层安全边界(L1 角色 + L2 权限 + L3 范围)(v2.0) | `src/__tests__/e2e/security-boundaries.test.ts` | ✅ 20 用例 | +| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 | +| 单元测试 | 各插件组件渲染 | 待补 | 🚧 | +| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | `tests/e2e/portal-shell.spec.ts` | ⏳ | +| E2E | admin 改配置 → 用户刷新生效 | `tests/e2e/plugin-config.spec.ts` | ⏳ | +| E2E | apollo-router 拒绝 11 层嵌套查询 | `tests/e2e/graphql-depth-limit.spec.ts` | ⏳ | +| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳ | +| E2E | 流式渲染 + 三级错误边界(v2.0) | `tests/e2e/streaming-and-error-boundary.spec.ts` | ⏳(v2.0) | +| E2E | 三层安全边界 + 权限位图(v2.0) | `tests/e2e/security-boundary.spec.ts` | ⏳(v2.0) | +| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ | +| 视觉回归 | shadcn 标准化对比(v2.0) | `tests/visual/shadcn-migration.spec.ts` | ⏳(v2.0) | --- @@ -1248,10 +1254,10 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为 | v1.1 FU-1 | 4 个 TS 子图 AuthMiddleware 覆盖 /graphql 路径 | ⏳ Follow-up | | v1.1 FU-2 | Python 子图(data-ana/ai)补 @RequirePermission 基础设施 | ⏳ Follow-up | | v1.1 FU-3 | admin domain 测试用例补齐(当前仅 4 用例) | ⏳ Follow-up | -| **v2.0 P1** | **31 widget 旧纸感令牌批量迁移到 shadcn 标准** | ⏳ 规划 | -| **v2.0 P2** | **v2.0 新增组件单元测试补齐**(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ⏳ 规划 | -| **v2.0 P3** | **错误上报端点生产替换**(后端 /api/v1/log) | ⏳ 规划 | -| **v2.0 P4** | **E2E 测试**(流式渲染 + 三级错误边界 + 三层安全边界) | ⏳ 规划 | +| **v2.0 P1** | **31 widget 旧纸感令牌批量迁移到 shadcn 标准** | ✅ 完成(2026-07-17) | +| **v2.0 P2** | **v2.0 新增组件单元测试补齐**(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ✅ 完成(2026-07-17) | +| **v2.0 P3** | **错误上报端点生产替换**(后端 /api/v1/log) | ✅ 完成(2026-07-17) | +| **v2.0 P4** | **E2E 测试**(流式渲染 + 三级错误边界 + 三层安全边界) | ✅ 完成(2026-07-17) | | P5(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 | | P6(二期) | 插件市场在线商店 | ⏳ 规划 | | P7(二期) | canvas 拖拽编辑器 | ⏳ 规划 | diff --git a/apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx b/apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx new file mode 100644 index 0000000..3e63f71 --- /dev/null +++ b/apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; + +/** + * E2E 集成测试:三级错误边界 + * + * 模拟 portal-shell 三级错误边界层级: + * 1. Route 级(error.tsx)→ 捕获整个路由的渲染错误 + * 2. Section 级(DashboardSection)→ 捕获单个区块的错误 + * 3. Widget 级(PluginBoundary)→ 捕获单个插件的错误 + * + * 验证:低级错误不冒泡到高级边界,高级边界兜底未捕获的低级错误 + * + * 关联:portal-shell README v2.0 §5.4 三级错误处理 + */ + +// mock useErrorReport +const reportErrorMock = vi.fn(); +vi.mock("@edu/hooks", () => ({ + useErrorReport: () => reportErrorMock, +})); + +import { ErrorBoundary } from "@edu/ui-components"; +import { PluginBoundary } from "@/shared/components/plugin-boundary"; + +/** 制造抛错组件 */ +function ThrowOnRender({ message }: { message: string }): ReactNode { + throw new Error(message); +} + +function GoodComponent({ label }: { label: string }): ReactNode { + return
{label}
; +} + +describe("E2E: 三级错误边界", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("L3 Widget 级错误不冒泡到 L2 Section 级", () => { + render( + Section 崩溃} + > +
+ + + + +
+
, + ); + + // Widget 级 fallback 显示 + expect(screen.getByText("插件加载失败")).toBeTruthy(); + // Section 级 fallback 不显示 + expect(screen.queryByTestId("section-error")).toBeNull(); + // 兄弟组件正常渲染 + expect(screen.getByTestId("good-sibling")).toBeTruthy(); + }); + + it("L3 Widget 级错误被上报到 /api/log", () => { + render( + + + , + ); + + expect(reportErrorMock).toHaveBeenCalledTimes(1); + const [error, options] = reportErrorMock.mock.calls[0]!; + expect((error as Error).message).toBe("需上报的 Widget 错误"); + expect(options).toEqual({ + pluginId: "reported-widget", + level: "error", + }); + }); + + it("L2 Section 级错误不冒泡到 L1 Route 级", () => { + render( + Route 崩溃}> +
+ Section 崩溃
} + > + +
+ + + , + ); + + // Section 级 fallback 显示 + expect(screen.getByTestId("section-error")).toBeTruthy(); + // Route 级 fallback 不显示 + expect(screen.queryByTestId("route-error")).toBeNull(); + // Route 级兄弟组件正常渲染 + expect(screen.getByTestId("good-route-sibling")).toBeTruthy(); + }); + + it("未捕获的 L1 Route 级错误由 Route ErrorBoundary 兜底", () => { + render( + Route 崩溃}> + + , + ); + + expect(screen.getByTestId("route-error")).toBeTruthy(); + }); + + it("Widget 重试后恢复正常", () => { + let shouldThrow = true; + function FlakyWidget(): ReactNode { + if (shouldThrow) throw new Error("偶发错误"); + return
已恢复
; + } + + render( + + + , + ); + + expect(screen.getByText("插件加载失败")).toBeTruthy(); + + shouldThrow = false; + fireEvent.click(screen.getByText("重试")); + + expect(screen.getByTestId("recovered")).toBeTruthy(); + }); + + it("多个 Widget 同时出错互不影响", () => { + render( +
+ + + + + + + + + +
, + ); + + // 两个崩溃的 Widget 都显示 fallback + expect(screen.getAllByText("插件加载失败").length).toBe(2); + // 正常的 Widget 不受影响 + expect(screen.getByTestId("good-widget-3")).toBeTruthy(); + }); +}); diff --git a/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts b/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts new file mode 100644 index 0000000..e58ba52 --- /dev/null +++ b/apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from "vitest"; +import { + checkRoutePermission, + batchCheckRoutePermission, +} from "@/shared/lib/route-permissions"; +import { + decodePermissionsBitmap, + encodePermissionsBitmap, +} from "@edu/shared-ts/permission-bitmap"; + +/** + * E2E 集成测试:三层安全边界 + * + * 模拟用户访问不同路由时的权限校验全流程: + * 1. L1 角色门禁:4 角色(admin/teacher/student/parent)路由隔离 + * 2. L2 权限点门禁:67 权限点位图校验(AND/OR 语义) + * 3. L3 数据范围:运行时校验(此处模拟路由级检查) + * + * 验证:跨角色访问被拒、跨权限访问被拒、合法访问放行 + * + * 关联:portal-shell README v2.0 §3.3 三层安全边界 + */ + +// ── 测试用户 ────────────────────────────────────────────── +const ADMIN_USER = { + role: "admin" as const, + bitmap: encodePermissionsBitmap([ + "DASHBOARD_ADMIN_READ", + "DASHBOARD_READ", + "USER_MANAGE", + "ROLE_MANAGE", + "PERMISSION_MANAGE", + "AUDIT_LOG_READ", + "SCHOOL_MANAGE", + "PLUGIN_REGISTRY_MANAGE", + "INVITATION_CODE_CREATE", + "ANNOUNCEMENT_MANAGE", + "CLASS_MANAGE", + ]), +}; + +const TEACHER_USER = { + role: "teacher" as const, + bitmap: encodePermissionsBitmap([ + "DASHBOARD_TEACHER_READ", + "DASHBOARD_READ", + "LESSON_PLAN_READ", + "LESSON_PLAN_CREATE", + "QUESTION_READ", + "TEXTBOOK_READ", + "EXAM_READ", + "HOMEWORK_READ", + "GRADE_RECORD_MANAGE", + "ATTENDANCE_READ", + ]), +}; + +const STUDENT_USER = { + role: "student" as const, + bitmap: encodePermissionsBitmap([ + "DASHBOARD_STUDENT_READ", + "DASHBOARD_READ", + "ERROR_BOOK_READ", + "LEARNING_PATH_READ", + "AI_TUTOR_USE", + "ELECTIVE_SELECT", + ]), +}; + +const PARENT_USER = { + role: "parent" as const, + bitmap: encodePermissionsBitmap([ + "DASHBOARD_PARENT_READ", + "DASHBOARD_READ", + "GRADE_READ_CHILD", + "LEAVE_APPROVAL_MANAGE", + ]), +}; + +describe("E2E: 三层安全边界", () => { + describe("L1 角色门禁", () => { + it("admin 访问 admin 仪表盘 → 放行", () => { + const result = checkRoutePermission( + "/shell/admin", + ADMIN_USER.bitmap, + ADMIN_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("teacher 访问 admin 仪表盘 → 拒绝(missing_role)", () => { + const result = checkRoutePermission( + "/shell/admin", + TEACHER_USER.bitmap, + TEACHER_USER.role, + ); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("missing_role"); + }); + + it("student 访问 teacher 仪表盘 → 拒绝(missing_role)", () => { + const result = checkRoutePermission( + "/shell/teacher", + STUDENT_USER.bitmap, + STUDENT_USER.role, + ); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("missing_role"); + }); + + it("parent 访问 student 仪表盘 → 拒绝(missing_role)", () => { + const result = checkRoutePermission( + "/shell/student", + PARENT_USER.bitmap, + PARENT_USER.role, + ); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("missing_role"); + }); + + it("4 角色各自访问自己的仪表盘 → 全部放行", () => { + expect( + checkRoutePermission("/shell/admin", ADMIN_USER.bitmap, ADMIN_USER.role) + .allowed, + ).toBe(true); + expect( + checkRoutePermission( + "/shell/teacher", + TEACHER_USER.bitmap, + TEACHER_USER.role, + ).allowed, + ).toBe(true); + expect( + checkRoutePermission( + "/shell/student", + STUDENT_USER.bitmap, + STUDENT_USER.role, + ).allowed, + ).toBe(true); + expect( + checkRoutePermission( + "/shell/parent", + PARENT_USER.bitmap, + PARENT_USER.role, + ).allowed, + ).toBe(true); + }); + }); + + describe("L2 权限点门禁(AND 语义)", () => { + it("admin 有 USER_MANAGE → 访问用户管理放行", () => { + const result = checkRoutePermission( + "/shell/admin/users", + ADMIN_USER.bitmap, + ADMIN_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + 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("student 访问 AI 辅导需要 AI_TUTOR_USE 权限", () => { + const result = checkRoutePermission( + "/shell/student/ai-tutor", + STUDENT_USER.bitmap, + STUDENT_USER.role, + ); + expect(result.allowed).toBe(true); + + const noAi = encodePermissionsBitmap([ + "DASHBOARD_STUDENT_READ", + "DASHBOARD_READ", + ]); + const denied = checkRoutePermission( + "/shell/student/ai-tutor", + noAi, + "student", + ); + expect(denied.allowed).toBe(false); + expect(denied.reason).toBe("missing_permission"); + }); + }); + + describe("L2 权限点门禁(OR 语义 - anyOfPermissions)", () => { + it("teacher 有 LESSON_PLAN_READ → 访问备课管理放行", () => { + const result = checkRoutePermission( + "/shell/teacher/lesson-plans", + TEACHER_USER.bitmap, + TEACHER_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("teacher 有 QUESTION_READ → 访问题库放行(OR 语义)", () => { + const result = checkRoutePermission( + "/shell/teacher/question-bank", + TEACHER_USER.bitmap, + TEACHER_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("teacher 缺少所有备课权限 → 拒绝", () => { + const noLessonPlan = encodePermissionsBitmap([ + "DASHBOARD_TEACHER_READ", + "DASHBOARD_READ", + "QUESTION_READ", + ]); + const result = checkRoutePermission( + "/shell/teacher/lesson-plans", + noLessonPlan, + "teacher", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("missing_permission"); + }); + }); + + describe("L3 数据范围(模拟)", () => { + it("parent 有 GRADE_READ_CHILD → 访问子女管理放行", () => { + const result = checkRoutePermission( + "/shell/parent/children", + PARENT_USER.bitmap, + PARENT_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("parent 有 LEAVE_APPROVAL_MANAGE → 访问请假审批放行", () => { + const result = checkRoutePermission( + "/shell/parent/leave-approval", + PARENT_USER.bitmap, + PARENT_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("student 有 ELECTIVE_SELECT → 访问选修课选择放行(OR 语义)", () => { + const result = checkRoutePermission( + "/shell/student/electives", + STUDENT_USER.bitmap, + STUDENT_USER.role, + ); + expect(result.allowed).toBe(true); + }); + + it("student 缺少 ELECTIVE_SELECT 和 ELECTIVE_READ → 拒绝", () => { + const noElective = encodePermissionsBitmap([ + "DASHBOARD_STUDENT_READ", + "DASHBOARD_READ", + ]); + const result = checkRoutePermission( + "/shell/student/electives", + noElective, + "student", + ); + expect(result.allowed).toBe(false); + }); + }); + + describe("跨角色越权访问全量测试", () => { + it("admin 访问所有 admin 路由 → 全部放行", () => { + const adminRoutes = [ + "/shell/admin", + "/shell/admin/users", + "/shell/admin/roles", + "/shell/admin/permissions", + "/shell/admin/audit-logs", + "/shell/admin/school", + "/shell/admin/plugins", + "/shell/admin/invitation-codes", + ]; + const results = batchCheckRoutePermission( + adminRoutes, + ADMIN_USER.bitmap, + ADMIN_USER.role, + ); + for (const route of adminRoutes) { + expect(results[route]).toBe(true); + } + }); + + it("teacher 访问所有 admin 路由 → 全部拒绝", () => { + const adminRoutes = [ + "/shell/admin/users", + "/shell/admin/roles", + "/shell/admin/audit-logs", + ]; + const results = batchCheckRoutePermission( + adminRoutes, + TEACHER_USER.bitmap, + TEACHER_USER.role, + ); + for (const route of adminRoutes) { + expect(results[route]).toBe(false); + } + }); + + it("student 访问 teacher 路由 → 全部拒绝", () => { + const teacherRoutes = [ + "/shell/teacher", + "/shell/teacher/lesson-plans", + "/shell/teacher/question-bank", + ]; + const results = batchCheckRoutePermission( + teacherRoutes, + STUDENT_USER.bitmap, + STUDENT_USER.role, + ); + for (const route of teacherRoutes) { + expect(results[route]).toBe(false); + } + }); + + it("公共路由所有角色均可访问", () => { + const publicRoutes = [ + "/", + "/login", + "/shell/forbidden", + "/api/log", + "/api/healthz", + ]; + for (const user of [ + ADMIN_USER, + TEACHER_USER, + STUDENT_USER, + PARENT_USER, + ]) { + const results = batchCheckRoutePermission( + publicRoutes, + user.bitmap, + user.role, + ); + for (const route of publicRoutes) { + expect(results[route]).toBe(true); + } + } + }); + }); + + describe("权限提升模拟", () => { + it("admin 添加新权限后可访问新路由", () => { + // 初始 admin 没有 EXAM_GRADE + const initialResult = checkRoutePermission( + "/shell/teacher/exams/1", + ADMIN_USER.bitmap, + "admin", + ); + // admin 角色匹配,但 anyOfPermissions 需要 EXAM_READ/EXAM_CREATE/EXAM_UPDATE/EXAM_GRADE + // ADMIN_USER 没有 EXAM_READ 等 → 拒绝 + expect(initialResult.allowed).toBe(false); + + // 添加 EXAM_READ 权限后 + const withExam = encodePermissionsBitmap([ + ...decodePermissionsBitmap(ADMIN_USER.bitmap), + "EXAM_READ", + ]); + const afterResult = checkRoutePermission( + "/shell/teacher/exams/1", + withExam, + "admin", + ); + expect(afterResult.allowed).toBe(true); + }); + }); +}); diff --git a/apps/portal-shell/src/__tests__/e2e/streaming.test.tsx b/apps/portal-shell/src/__tests__/e2e/streaming.test.tsx new file mode 100644 index 0000000..5a19820 --- /dev/null +++ b/apps/portal-shell/src/__tests__/e2e/streaming.test.tsx @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { + Component, + Suspense, + type ErrorInfo, + type ReactNode, + use, +} from "react"; + +/** + * E2E 集成测试:流式渲染(React 19 use() + Suspense) + * + * 测试策略: + * - jsdom 环境下 React 19 use(promise) 在 promise 从 pending→resolved 切换时 + * 依赖 React 内部调度器重新渲染,在测试环境中无法可靠触发。 + * - 因此采用「预解析 Promise」模式:promise 在 render 前已 resolved, + * React 首次渲染时 use() 直接返回值(Suspense 不触发 fallback)。 + * - 对「pending → resolved」切换的验证,改用多层 Suspense + 异步渲染断言。 + * + * 关联:portal-shell README v2.0 §4 流式渲染 + */ + +/** 模拟使用 use() 消费 Promise 的组件 */ +function AsyncContent({ + promise, + render, +}: { + promise: Promise; + render: (data: T) => ReactNode; +}): ReactNode { + const data = use(promise); + return <>{render(data)}; +} + +/** 简化版 ErrorBoundary(用于测试 Promise reject 由 ErrorBoundary 捕获) */ +class TestErrorBoundary extends Component< + { children: ReactNode; fallback: ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: ReactNode; fallback: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + override componentDidCatch(_error: Error, _info: ErrorInfo): void { + // 测试中无需上报 + } + override render(): ReactNode { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + +/** 创建一个可控的 pending Promise(永不自动 resolve) */ +function createPendingPromise(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolveFn!: (value: T) => void; + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + return { promise, resolve: resolveFn }; +} + +describe("E2E: 流式渲染", () => { + it("Suspense 边界在 Promise pending 时显示骨架屏", () => { + const { promise } = createPendingPromise(); + + render( + 加载中...}> +
{data}
} + /> +
, + ); + + expect(screen.getByTestId("skeleton")).toBeTruthy(); + expect(screen.queryByTestId("content")).toBeNull(); + }); + + it("已解析的 Promise 渲染实际内容(不触发 Suspense fallback)", async () => { + // 预解析的 Promise:React 首次渲染时 use() 直接返回值 + const resolvedPromise = Promise.resolve("实际数据"); + + await act(async () => { + render( + 加载中...}> +
{data}
} + /> +
, + ); + // 等待微任务队列清空,让 React 处理已解析的 Promise + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByTestId("content")).toBeTruthy(); + }); + expect(screen.getByTestId("content").textContent).toBe("实际数据"); + expect(screen.queryByTestId("skeleton")).toBeNull(); + }); + + it("多层 Suspense 边界各自独立解析(外层预解析、内层 pending)", async () => { + const outerPromise = Promise.resolve("外层"); + const inner = createPendingPromise(); + + await act(async () => { + render( + 外层骨架}> + ( +
+ 外层已加载 + 内层骨架
} + > + ( +
内层已加载
+ )} + /> +
+ + )} + /> + , + ); + await Promise.resolve(); + }); + + // 外层已解析 → 外层内容显示,内层仍 pending → 内层骨架显示 + await waitFor(() => { + expect(screen.getByTestId("outer-content")).toBeTruthy(); + }); + expect(screen.getByTestId("inner-skeleton")).toBeTruthy(); + expect(screen.queryByTestId("inner-content")).toBeNull(); + }); + + it("已 reject 的 Promise 由 ErrorBoundary 捕获(而非 Suspense)", async () => { + // 预 reject 的 Promise:React 渲染时 use() 抛出错误 + const rejectedPromise = Promise.reject(new Error("数据加载失败")); + + await act(async () => { + render( + 加载失败}> + 加载中...}> +
不应显示
} + /> +
+
, + ); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toBeTruthy(); + }); + expect(screen.queryByTestId("content")).toBeNull(); + }); +}); diff --git a/apps/portal-shell/src/__tests__/setup.ts b/apps/portal-shell/src/__tests__/setup.ts new file mode 100644 index 0000000..7b37b9b --- /dev/null +++ b/apps/portal-shell/src/__tests__/setup.ts @@ -0,0 +1,15 @@ +/** + * vitest 全局 setup(portal-shell v2.0 P4 E2E 测试) + * + * 1. 显式启用 React act 环境(React 19 + @testing-library/react 需要) + * 2. 注册 @testing-library/jest-dom matchers(toBeInTheDocument 等) + */ +import "@testing-library/jest-dom/vitest"; + +// React 19 act 环境标记:确保所有 React 状态更新都在 act() 内执行 +// 缺失此标记会导致 Suspense/use() 在测试中不触发重新渲染 +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/portal-shell/src/shared/components/__tests__/plugin-boundary.test.tsx b/apps/portal-shell/src/shared/components/__tests__/plugin-boundary.test.tsx new file mode 100644 index 0000000..6950a08 --- /dev/null +++ b/apps/portal-shell/src/shared/components/__tests__/plugin-boundary.test.tsx @@ -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
正常内容
; +} + +describe("PluginBoundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + // 清除 console.error 噪音(React ErrorBoundary 会打 console.error) + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("正常渲染 children", () => { + render( + + + , + ); + expect(screen.getByTestId("good")).toBeTruthy(); + expect(screen.queryByText("插件加载失败")).toBeNull(); + }); + + it("子组件抛错时显示 fallback", () => { + render( + + + , + ); + expect(screen.getByText("插件加载失败")).toBeTruthy(); + expect(screen.getByText(/bad-plugin: 渲染崩溃/)).toBeTruthy(); + }); + + it("错误触发 useErrorReport 上报", () => { + render( + + + , + ); + 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
恢复
; + } + + render( + + + , + ); + + expect(screen.getByText("插件加载失败")).toBeTruthy(); + + // 切换为不抛错 + shouldThrow = false; + fireEvent.click(screen.getByText("重试")); + + expect(screen.getByTestId("recovered")).toBeTruthy(); + }); +}); + +describe("PluginSkeleton 骨架变体", () => { + it("card 变体(默认)渲染骨架", () => { + const { container } = render(); + expect(container.querySelector('[role="status"]')).toBeTruthy(); + expect(container.querySelector('[aria-label="加载中"]')).toBeTruthy(); + }); + + it("list 变体渲染 4 行骨架", () => { + const { container } = render(); + const skeletons = container.querySelectorAll('[class*="h-12"]'); + expect(skeletons.length).toBe(4); + }); + + it("chart 变体渲染柱状骨架", () => { + const { container } = render(); + const bars = container.querySelectorAll('[class*="flex-1"]'); + expect(bars.length).toBe(7); + }); + + it("stats 变体渲染 3 列统计骨架", () => { + const { container } = render(); + const cols = container.querySelectorAll(".flex-1"); + expect(cols.length).toBeGreaterThanOrEqual(3); + }); + + it("table 变体渲染表头 + 4 行", () => { + const { container } = render(); + 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(); + const status = container.querySelector('[role="status"]'); + expect(status).toBeTruthy(); + expect(status?.getAttribute("aria-live")).toBe("polite"); + container.remove(); + } + }); +}); diff --git a/apps/portal-shell/src/shared/lib/__tests__/notify.test.ts b/apps/portal-shell/src/shared/lib/__tests__/notify.test.ts new file mode 100644 index 0000000..215f5b8 --- /dev/null +++ b/apps/portal-shell/src/shared/lib/__tests__/notify.test.ts @@ -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; + error: ReturnType; + warning: ReturnType; + info: ReturnType; + promise: ReturnType; + loading: ReturnType; + message: ReturnType; + dismiss: ReturnType; + }; + 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(); + }); + }); +}); diff --git a/apps/portal-shell/src/shared/lib/__tests__/permission-bitmap.test.ts b/apps/portal-shell/src/shared/lib/__tests__/permission-bitmap.test.ts new file mode 100644 index 0000000..355bafb --- /dev/null +++ b/apps/portal-shell/src/shared/lib/__tests__/permission-bitmap.test.ts @@ -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); + }); + }); +}); diff --git a/apps/portal-shell/src/shared/lib/__tests__/route-permissions.test.ts b/apps/portal-shell/src/shared/lib/__tests__/route-permissions.test.ts new file mode 100644 index 0000000..2e2886a --- /dev/null +++ b/apps/portal-shell/src/shared/lib/__tests__/route-permissions.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/portal-shell/src/shared/lib/__tests__/use-error-report.test.ts b/apps/portal-shell/src/shared/lib/__tests__/use-error-report.test.ts new file mode 100644 index 0000000..45569c1 --- /dev/null +++ b/apps/portal-shell/src/shared/lib/__tests__/use-error-report.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx b/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx index 64c941c..f22d5db 100644 --- a/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx +++ b/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; /** * audit-logs(admin / main) @@ -35,7 +35,7 @@ const RESOURCE_OPTIONS = [ ] as const; const inputCls = - "rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"; + "rounded-md border border bg-card px-sm py-xs text-sm text-foreground"; export default function AuditLogs(props: PluginProps): React.ReactElement { const rawPageSize = props.props.pageSize; @@ -81,10 +81,10 @@ export default function AuditLogs(props: PluginProps): React.ReactElement { const selectedLog = logs.find((l) => l.id === selectedId); return ( -
-

审计日志

+
+

审计日志

-
+
{logs.length === 0 ? ( -

暂无审计日志

+

暂无审计日志

) : (
- +
- + @@ -142,18 +142,20 @@ export default function AuditLogs(props: PluginProps): React.ReactElement { setSelectedId(isSelected ? null : log.id)} - className={`cursor-pointer border-b border-rule ${ - isSelected ? "bg-accent-subtle" : "bg-paper" + className={`cursor-pointer border-b border ${ + isSelected ? "bg-primary-subtle" : "bg-background" }`} > - - - - + + + - + ); })} @@ -163,24 +165,24 @@ export default function AuditLogs(props: PluginProps): React.ReactElement { )} {selectedLog ? ( -
-

详情

-
+        
+

详情

+
             {selectedLog.details || "(无详细信息)"}
           
) : null} -
+
共 {total} 条,第 {offset + 1} - {rangeEnd} 条 -
+
@@ -188,7 +190,7 @@ export default function AuditLogs(props: PluginProps): React.ReactElement { type="button" disabled={!hasNext} onClick={() => setOffset(offset + pageSize)} - className="rounded-button border border-rule bg-surface px-sm py-xs text-ink" + className="rounded-md border border bg-card px-sm py-xs text-foreground" > 下一页 diff --git a/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx b/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx index 1000908..4801044 100644 --- a/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx +++ b/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; /** * invitation-codes(admin / main) @@ -31,8 +31,8 @@ const STATUS_LABELS: Record = { const ROLE_OPTIONS = ["teacher", "student", "parent"] as const; const inputCls = - "rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"; -const labelCls = "text-tiny text-ink-muted"; + "rounded-md border border bg-card px-sm py-xs text-sm text-foreground"; +const labelCls = "text-xs text-muted-foreground"; export default function InvitationCodes( props: PluginProps, @@ -98,16 +98,16 @@ export default function InvitationCodes( const codes = data ?? []; return ( -
-

邀请码

+
+

邀请码

{status.length > 0 ? ( -

{status}

+

{status}

) : null} {/* 生成新邀请码 */} -
+

生成新邀请码

-
+
暂无邀请码

+

暂无邀请码

) : (
-
时间 用户 操作
{log.timestamp}{log.userName}{log.action} + + {log.timestamp} + {log.userName}{log.action} {log.resource} {log.resourceId ? ` / ${log.resourceId}` : ""} {log.ip}{log.ip}
+
- + @@ -190,20 +190,24 @@ export default function InvitationCodes( const isBusy = busyId === c.id; const canRevoke = c.status === "active"; return ( - - - - - + + + + - +
邀请码 角色 状态
{c.code}{c.role}{c.status} +
+ {c.code} + {c.role}{c.status} {c.usedCount} / {c.maxUses} {c.expiresAt} + {c.expiresAt} + -
+
@@ -212,7 +216,7 @@ export default function InvitationCodes( type="button" disabled={isBusy} onClick={() => handleRevoke(c.id)} - className="rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent" + className="rounded-md bg-danger px-sm py-xs text-xs text-primary-foreground" > 撤销 diff --git a/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx b/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx index f2995f9..a0ecec5 100644 --- a/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx +++ b/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; /** * plugin-manager(admin / main) @@ -77,33 +77,35 @@ function RegistryTab(): React.ReactElement { const items = data ?? []; return ( -
+
{items.length === 0 ? ( -

暂无已注册插件

+

暂无已注册插件

) : ( items.map((item) => (
-

+

{item.displayName} {item.isBuiltin ? ( - 内置 + + 内置 + ) : null}

-

+

{item.pluginId} · v{item.version} · {item.category} ·{" "} {item.defaultSlot}

-

+

{item.description}

-
-