417 lines
21 KiB
TypeScript
417 lines
21 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs/promises';
|
||
import { createHash } from 'node:crypto';
|
||
import { createServer } from 'node:http';
|
||
import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@classhelper/shared';
|
||
import { prisma } from '../src/lib/db.js';
|
||
import { createApp } from '../src/app.js';
|
||
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
|
||
import { ensureDefaultGroups, loadEffectivePermissions } from '../src/lib/permissions.js';
|
||
import { registerEmitter } from '../src/realtime/bus.js';
|
||
import * as students from '../src/modules/students/students.service.js';
|
||
import * as classes from '../src/modules/classes/classes.service.js';
|
||
import * as permissions from '../src/modules/permissions/permissions.service.js';
|
||
import {
|
||
configurePromotion,
|
||
previewPromotion,
|
||
runPromotion,
|
||
promotionStatus,
|
||
} from '../src/modules/term/promotion.service.js';
|
||
import { deleteArchive, getArchivedYearDetail } from '../src/modules/archives/archives.service.js';
|
||
import { updateStudentSchema } from '../src/modules/students/students.schemas.js';
|
||
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
|
||
|
||
if (!process.env.DATABASE_URL?.includes('lifecycle-verification'))
|
||
throw new Error('只能对生命周期专用临时数据库运行');
|
||
let count = 0;
|
||
function check(name: string, fn: () => unknown) {
|
||
fn();
|
||
console.log(`通过 ${++count}:${name}`);
|
||
}
|
||
async function denied(action: Promise<unknown>, status: number) {
|
||
await assert.rejects(action, (error: unknown) => (error as { status?: number }).status === status);
|
||
}
|
||
const account = async (name: string, role: string) => {
|
||
const row = await prisma.user.create({
|
||
data: { username: name, name, role, passwordHash: '隔离测试,不登录' },
|
||
});
|
||
return { sub: row.id, username: row.username, name, role, classId: null } as TokenPayload;
|
||
};
|
||
const admin = await account('临时管理员', 'ADMIN'),
|
||
teacher = await account('临时班主任', 'TEACHER'),
|
||
manager = await account('临时权限协助', 'TEACHER');
|
||
await ensureDefaultGroups();
|
||
const cls = async (
|
||
name: string,
|
||
grade: string,
|
||
enrollmentYear: number,
|
||
classIndex: number,
|
||
teacherId = teacher.sub,
|
||
) => prisma.class.create({ data: { name, grade, enrollmentYear, classIndex, code: name, teacherId } });
|
||
const a = await cls('TESTA', '高一', 2025, 1),
|
||
b = await cls('TESTB', '高一', 2025, 2),
|
||
c = await cls('TESTC', '高二', 2024, 1, admin.sub);
|
||
const events: { classId: string; event: string }[] = [];
|
||
registerEmitter((rooms, event) => {
|
||
for (const room of rooms) events.push({ classId: room.replace('class:', ''), event });
|
||
});
|
||
const server = createServer(createApp());
|
||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}/api`;
|
||
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
|
||
const response = await fetch(base + url, {
|
||
method,
|
||
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
|
||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||
});
|
||
const data = await response.json();
|
||
assert.equal(response.status, expected, `${method} ${url}: ${JSON.stringify(data)}`);
|
||
return data.data;
|
||
}
|
||
try {
|
||
check('资料 schema 拒绝班级和状态字段', () => {
|
||
assert.equal(updateStudentSchema.safeParse({ status: 'graduated' }).success, false);
|
||
assert.equal(updateStudentSchema.safeParse({ classId: b.id }).success, false);
|
||
});
|
||
const empty = await permissions.createGroup(admin, { name: '空组', keys: [] });
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [empty.id] });
|
||
for (const url of [
|
||
'/classes',
|
||
`/classes/${a.id}`,
|
||
`/classes/${a.id}/students`,
|
||
`/schedules?classId=${a.id}`,
|
||
`/homeworks?classId=${a.id}`,
|
||
`/notifications?classId=${a.id}`,
|
||
'/students',
|
||
'/term',
|
||
])
|
||
await request(url, teacher, 'GET', undefined, 403);
|
||
await request('/homeworks', teacher, 'POST', {}, 403);
|
||
check('取消读写键立即拒绝所有对应入口', () =>
|
||
assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true),
|
||
);
|
||
const delegate = await account('临时教务授权', 'TEACHER');
|
||
const governance = await permissions.createGroup(admin, {
|
||
name: '教务授权',
|
||
scope: 'ALL',
|
||
keys: ['class:read', 'class:manage', 'archive:manage', 'promotion:manage'],
|
||
});
|
||
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [governance.id] });
|
||
const delegatedClass = await request(
|
||
'/classes',
|
||
delegate,
|
||
'POST',
|
||
{ grade: '高一', enrollmentYear: 2028, classIndex: 99 },
|
||
201,
|
||
);
|
||
await request(`/classes/${delegatedClass.id}`, delegate, 'PATCH', { termWeeks: 10 });
|
||
await request(`/classes/${delegatedClass.id}`, delegate, 'DELETE');
|
||
await request('/archives', delegate);
|
||
await request('/term/promotion', delegate);
|
||
check('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {});
|
||
const allRoster = await permissions.createGroup(admin, {
|
||
name: '全校名单',
|
||
scope: 'ALL',
|
||
keys: ['roster:read', 'roster:manage'],
|
||
});
|
||
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [allRoster.id] });
|
||
const unassigned = await students.createStudent(delegate, {
|
||
studentNo: 'UNASSIGNED',
|
||
name: '未分班测试学生',
|
||
});
|
||
assert(
|
||
(await students.listStudents(delegate, { includeArchived: false })).some(
|
||
(student) => student.id === unassigned.id,
|
||
),
|
||
);
|
||
assert((await students.listTransfers(delegate, { studentId: unassigned.id })).items.length > 0);
|
||
check('全校名单授权包含未分班学生及其历史', () => {});
|
||
|
||
const readAll = await permissions.createGroup(admin, {
|
||
name: '全校班级只读',
|
||
scope: 'ALL',
|
||
keys: ['class:read'],
|
||
});
|
||
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:manage'] });
|
||
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] });
|
||
const foreignDetail = await request(`/classes/${a.id}`, delegate);
|
||
assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键');
|
||
await request(`/classes/${a.id}`, delegate, 'PATCH', { termWeeks: 11 }, 403);
|
||
check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {});
|
||
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
|
||
assert((await loadEffectivePermissions(teacher)).has('roster:manage'));
|
||
const additional = await permissions.createGroup(admin, {
|
||
name: '追加升级权限',
|
||
scope: 'ALL',
|
||
keys: ['promotion:manage'],
|
||
});
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
|
||
check('无组回落、显式默认组与多组并集', () => {});
|
||
assert((await loadEffectivePermissions(teacher)).has('promotion:manage'));
|
||
await denied(
|
||
permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }),
|
||
403,
|
||
);
|
||
const assistant = await permissions.createGroup(admin, {
|
||
name: '权限协助',
|
||
keys: ['permission:manage', 'roster:read'],
|
||
});
|
||
await permissions.assignUserGroups(admin, manager.sub, { groupIds: [assistant.id] });
|
||
await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403);
|
||
await denied(
|
||
permissions.createGroup(manager, { name: '越范围', scope: 'ALL', keys: ['roster:read'] }),
|
||
403,
|
||
);
|
||
await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403);
|
||
await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403);
|
||
check('跨角色默认组、越键、越范围和默认组编辑均拒绝', () => {});
|
||
await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400);
|
||
await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400);
|
||
check('范围统一归一化并拒绝空值和未知年级', () =>
|
||
assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'),
|
||
);
|
||
const memberList = await permissions.listMembers();
|
||
check('成员含真实权限与范围,组成员包含默认回落账号', () =>
|
||
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('promotion:manage')),
|
||
);
|
||
const audits = await permissions.listAudits();
|
||
check('审计记录操作人与变更前后快照', () =>
|
||
assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)),
|
||
);
|
||
let queryCount = 0;
|
||
const restoreMethods: (() => void)[] = [];
|
||
for (const model of [prisma.permissionGroup, prisma.userPermissionGroup, prisma.user]) {
|
||
for (const method of ['findMany', 'findUnique', 'upsert']) {
|
||
const original = Reflect.get(model, method);
|
||
Object.defineProperty(model, method, {
|
||
configurable: true,
|
||
value: (...args: unknown[]) => {
|
||
queryCount++;
|
||
return Reflect.apply(original, model, args);
|
||
},
|
||
});
|
||
restoreMethods.push(() =>
|
||
Object.defineProperty(model, method, { configurable: true, value: original }),
|
||
);
|
||
}
|
||
}
|
||
try {
|
||
await permissions.listMembers();
|
||
const small = queryCount;
|
||
queryCount = 0;
|
||
await prisma.user.createMany({
|
||
data: Array.from({ length: 30 }, (_, i) => ({
|
||
username: `批量账号${i}`,
|
||
name: `成员${i}`,
|
||
role: 'TEACHER',
|
||
passwordHash: '临时',
|
||
})),
|
||
});
|
||
await permissions.listMembers();
|
||
check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small));
|
||
} finally {
|
||
restoreMethods.forEach((restore) => restore());
|
||
}
|
||
const scheduleOnly = await permissions.createGroup(admin, { name: '仅课表编辑', keys: ['schedule:write'] });
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [scheduleOnly.id] });
|
||
const layoutPreview = {
|
||
classId: a.id,
|
||
payload: { TimeLayouts: [{ Name: '第一节', StartTime: '08:00', EndTime: '08:45', TimeType: 0 }] },
|
||
};
|
||
await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview, 403);
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
|
||
const parsedLayout = await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview);
|
||
check('课表导入键取消后真实预览入口 403,有键可解析', () => assert.equal(parsedLayout.items.length, 1));
|
||
const student = await students.createStudent(admin, { studentNo: 'S1', name: '学生一', classId: a.id });
|
||
await request(`/students/${student.id}`, admin, 'PATCH', { status: 'graduated' }, 400);
|
||
await request(`/students/${student.id}`, admin, 'PATCH', { classId: b.id }, 400);
|
||
check('HTTP 旧编辑请求明确返回 400', () => {});
|
||
await prisma.$executeRawUnsafe(
|
||
`CREATE TRIGGER lifecycle_fail BEFORE INSERT ON StudentClassTransfer BEGIN SELECT RAISE(ABORT, '历史写入故障'); END`,
|
||
);
|
||
await assert.rejects(students.transferStudents(admin, { studentIds: [student.id], toClassId: b.id }));
|
||
assert.equal((await prisma.student.findUniqueOrThrow({ where: { id: student.id } })).classId, a.id);
|
||
await prisma.$executeRawUnsafe('DROP TRIGGER lifecycle_fail');
|
||
check('注入历史写入故障后学生班级事务回滚', () => {});
|
||
const moved = await students.transferStudents(teacher, {
|
||
studentIds: [student.id],
|
||
toClassId: b.id,
|
||
note: '同年级调班',
|
||
});
|
||
check('班主任调班按名单键授权、批次精确返回、两班广播', () => {
|
||
assert.equal(moved.transfers[0].fromGrade, '高一');
|
||
assert(moved.batchId);
|
||
assert(events.some((e) => e.classId === a.id) && events.some((e) => e.classId === b.id));
|
||
});
|
||
await denied(students.transferStudents(teacher, { studentIds: [student.id], toClassId: c.id }), 403);
|
||
await denied(students.transferStudents(admin, { studentIds: [student.id], toClassId: c.id }), 409);
|
||
check('源目标范围与跨年级调班限制生效', () => {});
|
||
await students.transferOutStudents(admin, { studentIds: [student.id], note: '迁居', toSchool: '另一学校' });
|
||
const history = await students.listTransfers(admin, { studentId: student.id });
|
||
check('转出状态筛选与转出历史均可见', () => {
|
||
assert(history.items.some((h) => h.kind === 'transfer-out' && h.toSchool === '另一学校'));
|
||
});
|
||
assert.equal(
|
||
(await students.listStudents(admin, { status: 'transferred', includeArchived: false })).length,
|
||
1,
|
||
);
|
||
await request(`/students/${student.id}/transfers`, teacher);
|
||
await denied(students.transferOutStudents(admin, { studentIds: [student.id] }), 409);
|
||
await students.transferInStudent(admin, { studentId: student.id, toClassId: a.id, fromSchool: '另一学校' });
|
||
await students.transferInStudent(admin, {
|
||
studentNo: 'S2',
|
||
name: '学生二',
|
||
toClassId: a.id,
|
||
fromSchool: '其他学校',
|
||
});
|
||
check('重复转出拒绝,新建转入与历史学生重入有完整记录', () => {});
|
||
await students.deleteStudent(admin, student.id);
|
||
assert.equal(
|
||
(await students.listStudents(admin, { status: 'inactive', includeArchived: false })).length,
|
||
1,
|
||
);
|
||
await students.restoreStudent(admin, student.id, { toClassId: a.id });
|
||
await denied(students.deleteStudent(admin, student.id, { hard: true, confirm: true }), 409);
|
||
check('停用、复学和有历史学生的硬删除保护', () => {});
|
||
const device: TokenPayload = {
|
||
sub: a.id,
|
||
role: 'CLASS_DEVICE',
|
||
classId: a.id,
|
||
name: a.name,
|
||
username: a.code,
|
||
classSession: true,
|
||
};
|
||
for (const url of [
|
||
`/classes/${a.id}/students`,
|
||
`/schedules?classId=${a.id}`,
|
||
`/notifications?classId=${a.id}`,
|
||
])
|
||
await request(url, device);
|
||
await request(
|
||
'/homeworks',
|
||
device,
|
||
'POST',
|
||
{ classId: a.id, title: '临时作业', content: '完成练习', assignDate: '2026-10-23' },
|
||
201,
|
||
);
|
||
await request(
|
||
'/notifications',
|
||
device,
|
||
'POST',
|
||
{ classId: a.id, title: '临时通知', content: '隔离验收' },
|
||
201,
|
||
);
|
||
await request('/grades', device, 'POST', {}, 403);
|
||
await request(
|
||
'/calls',
|
||
device,
|
||
'POST',
|
||
{ classId: a.id, studentId: student.id, quickPhrase: '请到办公室' },
|
||
201,
|
||
);
|
||
check('班级端空权限保留名单课表通知和代录作业,禁止写成绩', () => {});
|
||
const g = await cls('TESTG', '高三', 2023, 1);
|
||
for (const [i, status] of ['active', 'transferred', 'inactive'].entries())
|
||
await prisma.student.create({
|
||
data: { studentNo: `G${i}`, name: `毕业班学生${i}`, classId: g.id, status },
|
||
});
|
||
await prisma.termWeek.create({
|
||
data: { classId: a.id, weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' },
|
||
});
|
||
const plan = defaultPromotionPlan(1);
|
||
plan.resetTermWeeks = false;
|
||
plan.termStartDate = '2026-09-08';
|
||
await configurePromotion({ month: 8, day: 1, hour: 0, plan }, admin);
|
||
const before = await prisma.promotionRun.count();
|
||
const preview = await previewPromotion(2026);
|
||
check('预览逐班逐状态、不写库、保留周次有警告', () => {
|
||
assert.equal(preview.blockers.length, 0);
|
||
assert.equal(preview.termWeekDeleteCount, 0);
|
||
assert(preview.tracks[0].classes.find((c) => c.id === g.id)?.transferred === 1);
|
||
});
|
||
assert.equal(await prisma.promotionRun.count(), before);
|
||
await denied(runPromotion(2026, { actor: admin, planHash: '0'.repeat(64), confirm: true }), 409);
|
||
const due = (await promotionStatus()).year;
|
||
await denied(runPromotion(due + 1, { actor: admin, planHash: preview.planHash, confirm: true }), 400);
|
||
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
|
||
await denied(runPromotion(due - 1, { actor: teacher, planHash: preview.planHash, confirm: true }), 403);
|
||
check('过期哈希、未来年份与教师补跑过去学年均拒绝', () => {});
|
||
const result = await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
|
||
assert.equal(result.status, 'done');
|
||
if (!('snapshotPath' in result)) throw new Error('执行未返回快照');
|
||
const awaitedContent = await fs.readFile(result.snapshotPath);
|
||
check('快照 SHA256 与磁盘内容一致、执行记录有操作人明细', () =>
|
||
assert.equal(result.snapshotSha256, createHash('sha256').update(awaitedContent).digest('hex')),
|
||
);
|
||
const archived = await prisma.archivedYear.findUniqueOrThrow({ where: { enrollmentYear: 2023 } });
|
||
const detail = await getArchivedYearDetail(admin, archived.id);
|
||
check('毕业转出停用全部挂届别、状态保持且统计完整', () => {
|
||
assert.equal(detail.graduates.length, 1);
|
||
assert.equal(detail.transferred.length, 1);
|
||
assert.equal(detail.inactive.length, 1);
|
||
assert.equal(detail.inactiveCount, 1);
|
||
});
|
||
assert.equal(await prisma.termWeek.count(), 1);
|
||
const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2026, grade: '高一' } });
|
||
assert.equal(skeleton.teacherId, null);
|
||
assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0);
|
||
assert.equal(
|
||
(await prisma.appSetting.findUniqueOrThrow({ where: { key: 'term.startDate' } })).value,
|
||
'2026-09-08',
|
||
);
|
||
await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
|
||
assert.equal(await prisma.archivedYear.count(), 1);
|
||
check('升级日期按计划、周次保留、新班无任课、重复运行幂等', () => {});
|
||
await denied(classes.addStudent(admin, a.id, { studentNo: 'G0', name: '不能复活' }), 409);
|
||
await denied(students.transferOutStudents(admin, { studentIds: [detail.graduates[0].id] }), 409);
|
||
await students.restoreStudent(admin, detail.transferred[0].id, { toClassId: a.id });
|
||
check('归档学生不可复活或转出,归档转出学生可指定在读班撤回', () => {});
|
||
await deleteArchive(admin, archived.id);
|
||
assert.equal((await prisma.student.findUniqueOrThrow({ where: { studentNo: 'G2' } })).status, 'inactive');
|
||
check('撤销归档保留停用状态,不回滚升级周次', () => assert.equal(detail.inactive[0].status, 'inactive'));
|
||
const unknown = await cls('TESTUNKNOWN', '未知学段', 2022, 10);
|
||
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'UNKNOWN_GRADE'));
|
||
await prisma.class.delete({ where: { id: unknown.id } });
|
||
await prisma.class.create({
|
||
data: { name: 'CODECONFLICT', code: 'N202701', grade: '高一', enrollmentYear: 2027, classIndex: 2 },
|
||
});
|
||
check('未知年级与新班码冲突在预览阶段阻断', () => {});
|
||
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'CODE_CONFLICT'));
|
||
await prisma.class.deleteMany({ where: { name: 'CODECONFLICT' } });
|
||
const duplicate = await cls('DUPLICATE', '高二', 2025, 1);
|
||
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'DUPLICATE_INDEX'));
|
||
await prisma.class.delete({ where: { id: duplicate.id } });
|
||
check('同轨道同届重复班号被阻断', () => {});
|
||
await request(`/classes/${a.id}`, admin, 'PATCH', { grade: '任意年级' }, 400);
|
||
await denied(classes.promoteClasses(admin, { classIds: [c.id] }), 409);
|
||
await classes.promoteClasses(admin, { classIds: [a.id] });
|
||
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: a.id } })).grade, '高三');
|
||
check('手动升级只到下一学段,毕业班与直接修改年级被拒绝', () => {});
|
||
const junior = await cls('JUNIOR', '初一', 2025, 1);
|
||
const multi = defaultPromotionPlan(0);
|
||
multi.tracks.push({
|
||
id: 'junior',
|
||
name: '初中',
|
||
entryClassCount: 0,
|
||
stages: [{ grade: '初一' }, { grade: '初二' }, { grade: '初三', terminal: true }],
|
||
});
|
||
await configurePromotion({ month: 8, day: 1, hour: 0, plan: multi }, admin);
|
||
const multiPreview = await previewPromotion(2099);
|
||
assert.equal(multiPreview.blockers.length, 0);
|
||
assert.equal(multiPreview.termWeekDeleteCount, 1);
|
||
await runPromotion(2099);
|
||
check('多轨道分别升级,确认重置后仅清除预览中的周次', () => {});
|
||
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: junior.id } })).grade, '初二');
|
||
assert.equal(await prisma.termWeek.count(), 0);
|
||
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||
const auditCount = snapshot.counts.PermissionAudit;
|
||
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
||
check('全库快照恢复保留审计、生命周期和执行明细', () => assert(auditCount > 0));
|
||
assert.equal(await prisma.permissionAudit.count(), auditCount);
|
||
console.log(`=== 权限与生命周期:${count}/${count} 项通过 ===`);
|
||
} finally {
|
||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||
await prisma.$disconnect();
|
||
}
|