refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled

- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验
- UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内
- 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过)
- 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007)
- 项目规则: 架构图优先规则,改码必同步图
- 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫
- 无障碍: skip-link、aria-label、prefers-reduced-motion
- 性能: next/font优化、next/image、代码分割
This commit is contained in:
SpecialX
2026-06-16 23:38:33 +08:00
parent 99f116cb64
commit 125f7ec54c
75 changed files with 9480 additions and 3289 deletions

View File

@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
authMock: vi.fn(),
getUserProfileMock: vi.fn(),
redirectMock: vi.fn((target: string) => {
throw new Error(`REDIRECT:${target}`)
}),
@@ -12,10 +11,6 @@ vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/modules/users/data-access", () => ({
getUserProfile: mocks.getUserProfileMock,
}))
vi.mock("next/navigation", () => ({
redirect: mocks.redirectMock,
}))
@@ -35,33 +30,36 @@ describe("dashboard route dispatcher", () => {
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects to login when user profile is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.getUserProfileMock.mockResolvedValue(null)
it("redirects to login when user is missing", async () => {
mocks.authMock.mockResolvedValue({ user: null })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects admin to admin dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "admin" })
it("redirects admin (school:manage) to admin dashboard", async () => {
mocks.authMock.mockResolvedValue({
user: { id: "u_admin", roles: ["admin"], permissions: ["school:manage"] },
})
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/admin/dashboard")
})
it("redirects student to student dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "student" })
it("redirects student (homework:submit without exam:create) to student dashboard", async () => {
mocks.authMock.mockResolvedValue({
user: { id: "u_student", roles: ["student"], permissions: ["homework:submit"] },
})
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
})
it("redirects parent to parent dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_parent" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "parent" })
mocks.authMock.mockResolvedValue({
user: { id: "u_parent", roles: ["parent"], permissions: ["exam:read"] },
})
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/parent/dashboard")
})
it("falls back to student dashboard when role is unknown", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
it("redirects teacher (with exam:create) to teacher dashboard", async () => {
mocks.authMock.mockResolvedValue({
user: { id: "u_teacher", roles: ["teacher"], permissions: ["exam:create", "exam:read"] },
})
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/teacher/dashboard")
})
})

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = vi.fn()
const requirePermissionMock = vi.fn()
const revalidatePathMock = vi.fn()
const createIdMock = vi.fn()
@@ -38,7 +38,7 @@ const mocks = vi.hoisted(() => {
)
return {
authMock,
requirePermissionMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
@@ -60,8 +60,14 @@ const mocks = vi.hoisted(() => {
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
vi.mock("@/shared/lib/auth-guard", () => ({
requirePermission: mocks.requirePermissionMock,
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
},
}))
vi.mock("next/cache", () => ({
@@ -105,9 +111,6 @@ vi.mock("@/shared/db/schema", () => ({
updatedAt: "updatedAt",
score: "score",
},
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}))
import {
@@ -117,14 +120,31 @@ import {
submitHomeworkAction,
} from "@/modules/homework/actions"
function studentCtx(userId = "u_student") {
return {
userId,
roles: ["student"],
permissions: ["homework:submit"],
dataScope: { type: "class_members" as const },
}
}
function teacherCtx(userId = "u_teacher") {
return {
userId,
roles: ["teacher"],
permissions: ["homework:grade"],
dataScope: { type: "class_taught" as const, classIds: [] },
}
}
describe("homework action flow", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("starts submission for assigned student", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_1",
status: "published",
@@ -153,8 +173,7 @@ describe("homework action flow", () => {
})
it("blocks submission when assignment is past due", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_1",
studentId: "u_student",
@@ -174,8 +193,7 @@ describe("homework action flow", () => {
})
it("submits started homework before due time", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_2",
studentId: "u_student",
@@ -198,8 +216,7 @@ describe("homework action flow", () => {
})
it("blocks start when attempts are exhausted", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_2",
status: "published",
@@ -217,8 +234,7 @@ describe("homework action flow", () => {
})
it("grades submission and writes total score", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
mocks.requirePermissionMock.mockResolvedValue(teacherCtx())
const formData = new FormData()
formData.set("submissionId", "sub_1")
@@ -239,8 +255,7 @@ describe("homework action flow", () => {
})
it("saves new answer for started submission", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_3",
studentId: "u_student",
@@ -267,8 +282,7 @@ describe("homework action flow", () => {
})
it("updates existing answer for started submission", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_4",
studentId: "u_student",

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = vi.fn()
const requirePermissionMock = vi.fn()
const revalidatePathMock = vi.fn()
const createIdMock = vi.fn()
@@ -27,13 +27,10 @@ const mocks = vi.hoisted(() => {
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
homeworkAssignments: { id: "id" },
homeworkSubmissions: { id: "id" },
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}
return {
authMock,
requirePermissionMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
@@ -48,8 +45,14 @@ const mocks = vi.hoisted(() => {
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
vi.mock("@/shared/lib/auth-guard", () => ({
requirePermission: mocks.requirePermissionMock,
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
},
}))
vi.mock("next/cache", () => ({
@@ -118,14 +121,33 @@ vi.mock("@/shared/db", () => ({
import { createHomeworkAssignmentAction } from "@/modules/homework/actions"
/** Helper to create a default admin auth context */
function adminCtx() {
return {
userId: "u_admin",
roles: ["admin"],
permissions: ["homework:create"],
dataScope: { type: "all" as const },
}
}
/** Helper to create a teacher auth context */
function teacherCtx(userId = "u_teacher") {
return {
userId,
roles: ["teacher"],
permissions: ["homework:create"],
dataScope: { type: "class_taught" as const, classIds: ["class_5"], subjectIds: ["subject_science"] },
}
}
describe("createHomeworkAssignmentAction", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("creates published assignment from exam with targets", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_1",
@@ -150,8 +172,7 @@ describe("createHomeworkAssignmentAction", () => {
})
it("returns not found when source exam does not exist", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
mocks.examFindFirstMock.mockResolvedValue(null)
@@ -166,8 +187,7 @@ describe("createHomeworkAssignmentAction", () => {
})
it("blocks publish when class has no active students", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
mocks.classLimitMock.mockResolvedValue([{ id: "class_2", teacherId: "teacher_2" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_2",
@@ -189,8 +209,8 @@ describe("createHomeworkAssignmentAction", () => {
})
it("blocks teacher when not assigned to class", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
const ctx = teacherCtx("u_teacher")
mocks.requirePermissionMock.mockResolvedValue(ctx)
mocks.classLimitMock.mockResolvedValue([{ id: "class_3", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_3",
@@ -212,8 +232,8 @@ describe("createHomeworkAssignmentAction", () => {
})
it("blocks teacher when exam subject is not assigned", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
const ctx = teacherCtx("u_teacher")
mocks.requirePermissionMock.mockResolvedValue(ctx)
mocks.classLimitMock.mockResolvedValue([{ id: "class_4", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_4",
@@ -235,8 +255,8 @@ describe("createHomeworkAssignmentAction", () => {
})
it("allows teacher assigned subject to publish", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
const ctx = teacherCtx("u_teacher")
mocks.requirePermissionMock.mockResolvedValue(ctx)
mocks.classLimitMock.mockResolvedValue([{ id: "class_5", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_5",
@@ -260,8 +280,8 @@ describe("createHomeworkAssignmentAction", () => {
})
it("returns exam subject missing for teacher-assigned class", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
const ctx = teacherCtx("u_teacher")
mocks.requirePermissionMock.mockResolvedValue(ctx)
mocks.classLimitMock.mockResolvedValue([{ id: "class_6", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_6",
@@ -283,8 +303,7 @@ describe("createHomeworkAssignmentAction", () => {
})
it("returns class not found when class is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
mocks.classLimitMock.mockResolvedValue([])
const formData = new FormData()

View File

@@ -1,45 +1,60 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("@/auth", () => ({
auth: (handler: (req: unknown) => unknown) => handler,
const { getTokenMock } = vi.hoisted(() => ({
getTokenMock: vi.fn(),
}))
import proxy from "@/proxy"
vi.mock("next-auth/jwt", () => ({
getToken: getTokenMock,
}))
type SessionRole = "admin" | "teacher" | "student" | "parent"
import { middleware } from "@/proxy"
const createRequest = (pathname: string, role?: SessionRole) => ({
const createRequest = (pathname: string) => ({
nextUrl: {
pathname,
clone: () => new URL(`http://localhost${pathname}`),
},
auth: role ? { user: { role } } : null,
url: `http://localhost${pathname}`,
})
describe("proxy route guard", () => {
it("redirects unauthenticated requests to login with callback", async () => {
const response = await proxy(createRequest("/teacher/dashboard") as never)
getTokenMock.mockResolvedValue(null)
const response = await middleware(createRequest("/teacher/dashboard") as never)
expect(response.status).toBe(307)
const location = response.headers.get("location") ?? ""
expect(location).toContain("/login")
expect(location).toContain("callbackUrl=%2Fteacher%2Fdashboard")
expect(location).toContain("callbackUrl=")
expect(decodeURIComponent(location)).toContain("/teacher/dashboard")
})
it("redirects student away from admin routes", async () => {
const response = await proxy(createRequest("/admin/dashboard", "student") as never)
it("redirects user without school:manage permission away from admin routes", async () => {
getTokenMock.mockResolvedValue({
permissions: ["homework:submit"],
roles: ["student"],
})
const response = await middleware(createRequest("/admin/dashboard") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/student/dashboard")
})
it("redirects parent away from management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "parent") as never)
it("redirects user without grade:manage permission away from management routes", async () => {
getTokenMock.mockResolvedValue({
permissions: ["exam:read"],
roles: ["parent"],
})
const response = await middleware(createRequest("/management/grade/insights") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/parent/dashboard")
})
it("allows teacher access to management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "teacher") as never)
it("allows user with grade:manage permission to access management routes", async () => {
getTokenMock.mockResolvedValue({
permissions: ["exam:read", "grade:manage"],
roles: ["teacher"],
})
const response = await middleware(createRequest("/management/grade/insights") as never)
expect(response.status).toBe(200)
expect(response.headers.get("location")).toBeNull()
})