拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
517 lines
23 KiB
TypeScript
517 lines
23 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { createServer } from 'node:http';
|
||
import { io as connect, type Socket } from 'socket.io-client';
|
||
import {
|
||
PERMISSION_KEYS,
|
||
PERMISSION_META,
|
||
SOCKET_CHANNELS,
|
||
SOCKET_ROOMS,
|
||
SOCKET_EVENTS,
|
||
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 } from '../src/lib/permissions.js';
|
||
import { classIdsFor } from '../src/lib/authz/index.js';
|
||
import { initRealtime } from '../src/realtime/socket.js';
|
||
import { emitToClass } from '../src/realtime/bus.js';
|
||
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
|
||
|
||
if (!process.env.DATABASE_URL?.includes('authz-verification'))
|
||
throw new Error('只能在授权验收临时数据库运行');
|
||
let count = 0;
|
||
const check = (name: string) => console.log(`通过 ${++count}:${name}`);
|
||
async function account(name: string, role: TokenPayload['role'] = 'TEACHER'): Promise<TokenPayload> {
|
||
const row = await prisma.user.create({
|
||
data: { username: name, name, role, passwordHash: '隔离夹具,不使用真实账号' },
|
||
});
|
||
return { sub: row.id, username: name, name, role, classId: null };
|
||
}
|
||
const admin = await account('authz-admin', 'ADMIN'),
|
||
aTeacher = await account('authz-A'),
|
||
bTeacher = await account('authz-B'),
|
||
unrelated = await account('authz-C'),
|
||
lead = await account('authz-grade'),
|
||
division = await account('authz-division');
|
||
await ensureDefaultGroups();
|
||
const cls = async (name: string, grade: string, teacherId: string, index: number) =>
|
||
prisma.class.create({
|
||
data: {
|
||
name,
|
||
grade,
|
||
teacherId,
|
||
classIndex: index,
|
||
enrollmentYear: 2026,
|
||
code: `AUTHZ${index}`,
|
||
passwordHash: '隔离',
|
||
},
|
||
});
|
||
const A = await cls('授权A', '高一', aTeacher.sub, 1),
|
||
B = await cls('授权B', '高一', bTeacher.sub, 2),
|
||
_C = await cls('授权C', '高二', bTeacher.sub, 3);
|
||
const course = async (classId: string, name: string, teacherId: string) =>
|
||
prisma.course.create({ data: { classId, name, teacherId } });
|
||
const mathB = await course(B.id, '数学', aTeacher.sub),
|
||
chineseB = await course(B.id, '语文', bTeacher.sub),
|
||
mathA = await course(A.id, '数学', aTeacher.sub);
|
||
const studentA = await prisma.student.create({ data: { studentNo: 'AUTHZA', name: '临时A', classId: A.id } }),
|
||
studentB = await prisma.student.create({ data: { studentNo: 'AUTHZB', name: '临时B', classId: B.id } });
|
||
const device = (c: typeof A): TokenPayload => ({
|
||
sub: c.id,
|
||
username: c.code,
|
||
name: c.name,
|
||
role: 'CLASS_DEVICE',
|
||
classId: c.id,
|
||
classSession: true,
|
||
classCode: c.code,
|
||
});
|
||
const aDevice = device(A),
|
||
bDevice = device(B);
|
||
const server = createServer(createApp()),
|
||
realtime = initRealtime(server);
|
||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`,
|
||
base = origin + '/api';
|
||
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
|
||
const res = await fetch(base + url, {
|
||
method,
|
||
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
|
||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||
});
|
||
const json = await res.json();
|
||
assert.equal(res.status, expected, `${method} ${url} ${JSON.stringify(json)}`);
|
||
return json.data;
|
||
}
|
||
async function group(name: string, keys: string[], scope = 'TEACHING', scopeValue = '') {
|
||
return prisma.permissionGroup.create({
|
||
data: { name, scope, scopeValue, items: { create: keys.map((key) => ({ key })) } },
|
||
});
|
||
}
|
||
async function assign(actor: TokenPayload, rows: { groupId: string; scope: string; scopeValue?: string }[]) {
|
||
await prisma.userPermissionGroup.deleteMany({ where: { userId: actor.sub } });
|
||
if (rows.length)
|
||
await prisma.userPermissionGroup.createMany({
|
||
data: rows.map((r) => ({ userId: actor.sub, ...r, scopeValue: r.scopeValue ?? '' })),
|
||
});
|
||
}
|
||
const sockets: Socket[] = [];
|
||
async function socket(actor: TokenPayload) {
|
||
const s = connect(origin, {
|
||
auth: { token: signToken(actor) },
|
||
transports: ['websocket'],
|
||
reconnection: false,
|
||
});
|
||
sockets.push(s);
|
||
const info = await new Promise<{ rooms: string[] }>((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('实时连接超时')), 5000);
|
||
s.once('connected', (data) => {
|
||
clearTimeout(timer);
|
||
resolve(data);
|
||
});
|
||
s.once('connect_error', reject);
|
||
});
|
||
return { s, rooms: info.rooms };
|
||
}
|
||
try {
|
||
// 旧残留管理员键全部装备给科任,管理员能力仍永久无效。
|
||
const old = await group('旧残留全能力', [...PERMISSION_KEYS], 'ALL');
|
||
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
|
||
const mine = await request('/permissions/me', aTeacher);
|
||
assert(
|
||
!mine.permissions.some((key: keyof typeof PERMISSION_META) => PERMISSION_META[key].kind === 'admin'),
|
||
);
|
||
assert.equal((await request('/permissions/me', admin)).permissions.length, PERMISSION_KEYS.length);
|
||
check('管理员全集;旧组残留管理员键对教师无效');
|
||
for (const [url, method, body] of [
|
||
[`/classes/${B.id}/class-account`, 'PATCH', { password: 'authz-test' }],
|
||
[`/classes/${B.id}/settings`, 'PATCH', { termWeeks: 12 }],
|
||
[`/classes/${B.id}/head-teacher`, 'PATCH', { teacherId: aTeacher.sub }],
|
||
[
|
||
`/classes/${B.id}/subject-teachers`,
|
||
'PUT',
|
||
{ assignments: [{ subjectName: '语文', teacherId: aTeacher.sub }] },
|
||
],
|
||
['/classes', 'POST', { grade: '高一', enrollmentYear: 2029, classIndex: 99 }],
|
||
['/teachers', 'POST', { username: 'intruder', name: '攻击', role: 'ADMIN' }],
|
||
[`/teachers/${admin.sub}`, 'PATCH', { role: 'TEACHER' }],
|
||
[`/teachers/${admin.sub}/reset-password`, 'POST', {}],
|
||
[`/teachers/${admin.sub}`, 'DELETE', undefined],
|
||
['/permissions/groups', 'POST', { name: '越权', keys: [] }],
|
||
['/system/settings', 'PUT', {}],
|
||
['/database/status', 'GET', undefined],
|
||
['/term', 'PUT', { weeks: [] }],
|
||
['/term/promotion', 'GET', undefined],
|
||
[
|
||
'/imports/table/commit',
|
||
'POST',
|
||
{ kind: 'classTeachers', fileName: 'authz.csv', contentBase64: '', mapping: {}, mode: 'append' },
|
||
],
|
||
] as const)
|
||
await request(url, aTeacher, method, body, 403);
|
||
assert.equal((await request(`/classes/${B.id}`, aTeacher)).code, undefined);
|
||
check('科任不能接管班级账号、学期设置、人员关系或组织写入口,班级码不泄漏');
|
||
await request(`/classes/${A.id}/class-account`, aTeacher, 'PATCH', { code: 'AUTHZA1' });
|
||
await request(`/classes/${A.id}/settings`, aTeacher, 'PATCH', { termWeeks: 12 });
|
||
await request(`/classes/${B.id}/class-account`, admin, 'PATCH', { code: 'AUTHZB1' });
|
||
check('本班班主任可改账号与学期设置;管理员可改其他班');
|
||
await request(`/teachers/${admin.sub}`, admin, 'PATCH', { role: 'TEACHER' }, 409);
|
||
await request(`/teachers/${admin.sub}`, admin, 'DELETE', undefined, 409);
|
||
check('最后一个管理员不可降级或删除');
|
||
const helper = await request(
|
||
'/teachers',
|
||
admin,
|
||
'POST',
|
||
{ username: 'authz-new', name: '新增教师', password: 'authz-pass' },
|
||
201,
|
||
);
|
||
await request(`/teachers/${helper.id}/reset-password`, admin, 'POST', { newPassword: 'authz-pass-2' });
|
||
await request(`/teachers/${helper.id}`, admin, 'DELETE');
|
||
check('管理员账号写入口正常且产生审计');
|
||
const readA = await group(
|
||
'A的关联读取',
|
||
[
|
||
'class:read',
|
||
'homework:read',
|
||
'notification:read',
|
||
'grade:read',
|
||
'dashboard:read',
|
||
'schedule:read',
|
||
'roster:read',
|
||
],
|
||
'HEAD',
|
||
);
|
||
const otherAll = await group('无关全校键', ['client:read'], 'ALL');
|
||
await assign(aTeacher, [
|
||
{ groupId: readA.id, scope: 'HEAD' },
|
||
{ groupId: otherAll.id, scope: 'ALL' },
|
||
]);
|
||
for (const url of [
|
||
`/grades/ranking?classId=${B.id}&examName=测试`,
|
||
`/grades/exams?classId=${B.id}`,
|
||
`/grades/collective?classId=${B.id}`,
|
||
`/homeworks?classId=${B.id}`,
|
||
`/notifications?classId=${B.id}`,
|
||
])
|
||
await request(url, aTeacher, 'GET', undefined, 403);
|
||
assert.deepEqual(
|
||
(await request('/classes', aTeacher)).map((c: { id: string }) => c.id),
|
||
[A.id],
|
||
);
|
||
const dashboard = await request('/dashboard/summary', aTeacher);
|
||
assert.equal(dashboard.classCount, 1);
|
||
assert.equal((await classIdsFor({ ...aTeacher }, undefined as never)).length, 0);
|
||
check('按键独立计算,设备全校读不能扩大内容读取;无键范围为空');
|
||
await assign(unrelated, [{ groupId: otherAll.id, scope: 'ALL' }]);
|
||
for (const url of ['/homeworks', '/notifications', '/dashboard/summary', `/grades/exams?classId=${B.id}`])
|
||
await request(url, unrelated, 'GET', undefined, 403);
|
||
const onlyDashboard = await group('仅看板', ['dashboard:read'], 'ALL');
|
||
await assign(unrelated, [{ groupId: onlyDashboard.id, scope: 'ALL' }]);
|
||
const hidden = await request('/dashboard/summary', unrelated);
|
||
assert.equal(hidden.gradeCount, 0);
|
||
assert.equal(hidden.studentCount, 0);
|
||
assert.equal(hidden.sections.grades, false);
|
||
check('宽范围无关能力不授予读权限,看板各段独立受控');
|
||
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
|
||
const hw = await prisma.homework.create({
|
||
data: {
|
||
classId: B.id,
|
||
courseId: chineseB.id,
|
||
title: '同事语文',
|
||
content: '隔离测试',
|
||
createdBy: bTeacher.sub,
|
||
},
|
||
});
|
||
const grade = await prisma.grade.create({
|
||
data: { classId: B.id, courseId: chineseB.id, studentId: studentB.id, examName: '授权考试', score: 80 },
|
||
});
|
||
await request(`/homeworks/${hw.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
|
||
await request(`/grades/${grade.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
|
||
await request(
|
||
`/homeworks/${hw.id}/status`,
|
||
aTeacher,
|
||
'PATCH',
|
||
{ completed: true, studentId: studentB.id },
|
||
403,
|
||
);
|
||
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'GET', undefined, 403);
|
||
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'PATCH', { notSubmittedStudentIds: [] }, 403);
|
||
await request(`/grades/exams`, aTeacher, 'PUT', { classId: B.id, name: '授权考试', countInTotal: true });
|
||
// 年级/全校授权可配置考试口径;仅任教范围的科任不能。
|
||
await assign(aTeacher, [{ groupId: old.id, scope: 'TEACHING' }]);
|
||
await request(
|
||
`/grades/exams`,
|
||
aTeacher,
|
||
'PUT',
|
||
{ classId: B.id, name: '授权考试', countInTotal: false },
|
||
403,
|
||
);
|
||
await request(`/classes/${B.id}/student-grade-query`, aTeacher, 'PATCH', { enabled: false }, 403);
|
||
check('原/目标科目双校验;未交名单与完成状态需要科目关系;科任不能改考试配置');
|
||
const ownHw = await request(
|
||
'/homeworks',
|
||
aTeacher,
|
||
'POST',
|
||
{ classId: B.id, courseId: mathB.id, title: '本人数学', content: '隔离测试' },
|
||
201,
|
||
);
|
||
await request(`/homeworks/${ownHw.id}/status`, bDevice, 'PATCH', { completed: true });
|
||
check('科任可发布本科作业,班级端可维护本班作业');
|
||
const layoutBody = {
|
||
classId: A.id,
|
||
name: '本班时间表',
|
||
points: [{ name: '第一节', startTime: '08:00', endTime: '08:40', timeType: 'LESSON' }],
|
||
};
|
||
const layout = await request('/time-layouts', aTeacher, 'POST', layoutBody, 201);
|
||
const foreign = await request(
|
||
'/time-layouts',
|
||
admin,
|
||
'POST',
|
||
{ ...layoutBody, classId: B.id, name: '别班时间表' },
|
||
201,
|
||
);
|
||
for (const method of ['PUT', 'DELETE'])
|
||
await request(
|
||
`/time-layouts/${foreign.id}`,
|
||
aTeacher,
|
||
method,
|
||
method === 'PUT' ? { ...layoutBody, classId: A.id } : undefined,
|
||
403,
|
||
);
|
||
await request(`/time-layouts/${foreign.id}/clone`, aTeacher, 'POST', { name: '偷复制' }, 403);
|
||
await request('/time-layouts', aTeacher, 'POST', { ...layoutBody, classId: null }, 403);
|
||
await request('/term', aTeacher, 'PUT', { classId: B.id, weeks: [] }, 403);
|
||
await request('/term', aTeacher, 'PUT', { classId: A.id, weeks: [] });
|
||
await request(`/time-layouts/${layout.id}`, aTeacher, 'DELETE');
|
||
check('时间表按存储班级校验;本班可写、跨班与全局拒绝;班级周次要求班主任');
|
||
const csv = `学号,姓名,班级\nAUTHZSAFE,首行,${A.name}\nAUTHZBAD,末行,${B.name}`;
|
||
await request(
|
||
'/imports/table/commit',
|
||
aTeacher,
|
||
'POST',
|
||
{
|
||
kind: 'students',
|
||
fileName: 'authz.csv',
|
||
contentBase64: Buffer.from('\ufeff' + csv).toString('base64'),
|
||
mapping: { studentNo: '学号', name: '姓名', className: '班级' },
|
||
mode: 'append',
|
||
},
|
||
403,
|
||
);
|
||
assert.equal(await prisma.student.count({ where: { studentNo: 'AUTHZSAFE' } }), 0);
|
||
check('跨班学生导入预检全部范围,403 不留下首行数据');
|
||
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
|
||
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { scope: 'ALL' }, 400);
|
||
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { keys: ['teacher:manage'] }, 400);
|
||
await request(
|
||
'/permissions/groups',
|
||
admin,
|
||
'POST',
|
||
{ name: '非法年级', scope: 'GRADE', scopeValue: '不存在', keys: ['class:read'] },
|
||
400,
|
||
);
|
||
await request('/permissions/groups', admin, 'POST', { name: '非法能力', keys: ['permission:manage'] }, 400);
|
||
const legacyTemplate = await group('保留旧停用键', ['class:read', 'class:manage']);
|
||
const updatedLegacyTemplate = await request(`/permissions/groups/${legacyTemplate.id}`, admin, 'PATCH', {
|
||
keys: ['class:read', 'grade:read'],
|
||
});
|
||
assert(updatedLegacyTemplate.inactiveKeys.includes('class:manage'));
|
||
assert.equal(
|
||
await prisma.permissionGroupItem.count({ where: { groupId: legacyTemplate.id, key: 'class:manage' } }),
|
||
1,
|
||
);
|
||
await assign(unrelated, [{ groupId: legacyTemplate.id, scope: 'ALL' }]);
|
||
assert(!(await request('/permissions/me', unrelated)).permissions.includes('class:manage'));
|
||
check('默认模板固定 TEACHING;不可保存管理员能力或未知年级');
|
||
const scoped = await request(
|
||
'/permissions/groups',
|
||
admin,
|
||
'POST',
|
||
{ name: '分配级范围', scope: 'HEAD', keys: ['class:read', 'grade:read', 'class:account'] },
|
||
201,
|
||
);
|
||
await request(`/permissions/users/${lead.sub}/groups`, admin, 'PUT', {
|
||
assignments: [{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高一' }],
|
||
});
|
||
await request(`/permissions/users/${division.sub}/groups`, admin, 'PUT', {
|
||
assignments: [{ groupId: scoped.id, scope: 'DIVISION', scopeValue: '高二,高一' }],
|
||
});
|
||
assert.equal((await request('/classes', lead)).length, 2);
|
||
assert.equal((await request('/classes', division)).length, 3);
|
||
const effective = await request(`/permissions/users/${lead.sub}/effective`, admin);
|
||
assert.equal(effective.entries.find((e: { key: string }) => e.key === 'class:account').classCount, 0);
|
||
const leadPermissions = await request('/permissions/me', lead);
|
||
assert.deepEqual(leadPermissions.classScopes['grade:read'].sort(), [A.id, B.id].sort());
|
||
await request(`/grades/ranking?classId=${B.id}&examName=授权考试`, lead);
|
||
await request(`/permissions/groups/${scoped.id}`, admin, 'PATCH', { scope: 'ALL' });
|
||
assert.equal((await request('/classes', lead)).length, 2);
|
||
check('模板建议范围变更不影响已有分配;级长/部长范围与班主任关系独立');
|
||
const query = encodeURIComponent(
|
||
JSON.stringify([
|
||
{ groupId: scoped.id, scope: 'HEAD', scopeValue: '' },
|
||
{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高二' },
|
||
]),
|
||
);
|
||
const merged = await request(`/permissions/users/${aTeacher.sub}/effective?assignments=${query}`, admin);
|
||
assert.equal(merged.entries.find((e: { key: string }) => e.key === 'class:read').classCount, 2);
|
||
assert(merged.conflicts.length > 0);
|
||
await request(`/permissions/users/${admin.sub}/groups`, admin, 'PUT', { assignments: [] }, 400);
|
||
check('同模板多范围正确合并并预览冲突,管理员不可分配');
|
||
const beforeDefault = await request('/permissions/me', bTeacher);
|
||
await request(`/permissions/users/${bTeacher.sub}/groups`, admin, 'PUT', { assignments: [] });
|
||
assert.equal((await request('/permissions/me', bTeacher)).fromGroups, false);
|
||
assert(beforeDefault.permissions.length > 0);
|
||
check('空分配恢复默认,无隐式自动预选转换');
|
||
await request(`/classes/${B.id}/student-grade-query`, admin, 'PATCH', { enabled: false });
|
||
for (const url of [
|
||
`/grades/ranking?classId=${B.id}&examName=授权考试`,
|
||
`/grades/exams?classId=${B.id}`,
|
||
`/grades/student/${studentB.studentNo}`,
|
||
'/grades/my',
|
||
])
|
||
await request(url, bDevice, 'GET', undefined, 403);
|
||
assert.equal((await request(`/grades/collective?classId=${B.id}`, bDevice)).mode, 'collective');
|
||
await request(`/grades/collective?classId=${B.id}`, aDevice, 'GET', undefined, 403);
|
||
check('班级端关闭查询后拒绝个人/排名/原始列表,保留本班集体聚合');
|
||
const hb = {
|
||
deviceKey: 'authz-device-b',
|
||
name: '临时设备',
|
||
clientVersion: '1.0.0',
|
||
platform: 'win32',
|
||
agentProtocol: 2,
|
||
};
|
||
const d = await request('/clients/heartbeat', bDevice, 'POST', hb);
|
||
await request('/clients/heartbeat', aTeacher, 'POST', hb, 403);
|
||
await request('/clients/heartbeat', aDevice, 'POST', hb, 403);
|
||
const command = await prisma.clientCommand.create({
|
||
data: { deviceId: d.deviceId, kind: 'lock', createdBy: admin.sub, payload: '隔离假执行' },
|
||
});
|
||
for (const actor of [aTeacher, aDevice])
|
||
await request(
|
||
`/clients/commands/${command.id}/ack`,
|
||
actor,
|
||
'POST',
|
||
{ deviceKey: hb.deviceKey, ok: true },
|
||
403,
|
||
);
|
||
await request(`/clients/commands/${command.id}/ack`, bDevice, 'POST', {
|
||
deviceKey: hb.deviceKey,
|
||
ok: true,
|
||
result: '未执行任何系统操作',
|
||
});
|
||
await request(
|
||
'/clients/update-progress',
|
||
aTeacher,
|
||
'POST',
|
||
{
|
||
deviceKey: hb.deviceKey,
|
||
progress: {
|
||
version: '1.1.0',
|
||
stage: 'downloading',
|
||
downloadedBytes: 0,
|
||
totalBytes: 1,
|
||
percent: 0,
|
||
error: '',
|
||
},
|
||
},
|
||
403,
|
||
);
|
||
check('心跳/进度/回执只接受本班班级端,教师知道 deviceKey 仍不能冒用');
|
||
await request('/update/client/releases', admin, 'POST', { version: '..' }, 400);
|
||
await request('/update/client/download/1.0.0', unrelated, 'GET', undefined, 403);
|
||
check('更新路径拒绝 ..,下载要求班级端或设备读能力及范围');
|
||
const readOnly = await group('只读作业实时', ['homework:read'], 'HEAD');
|
||
await assign(aTeacher, [{ groupId: readOnly.id, scope: 'TEACHING' }]);
|
||
const previousSubjectSocket = await socket(aTeacher);
|
||
assert(previousSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
|
||
const disconnected = new Promise<void>((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('任课导入后旧订阅未断开')), 5000);
|
||
previousSubjectSocket.s.once('disconnect', () => {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
});
|
||
});
|
||
await request('/imports/table/commit', admin, 'POST', {
|
||
kind: 'classTeachers',
|
||
fileName: 'relations.csv',
|
||
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
|
||
contentBase64: Buffer.from(
|
||
`\ufeff班级,科目,工号,姓名,角色\n${B.name},数学,${bTeacher.username},临时教师,科任`,
|
||
).toString('base64'),
|
||
mode: 'upsert',
|
||
});
|
||
await disconnected;
|
||
const updatedSubjectSocket = await socket(aTeacher);
|
||
assert(!updatedSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
|
||
check('任课导入变更后旧房间立即失效,重连按现行关系订阅');
|
||
|
||
await assign(aTeacher, [
|
||
{ groupId: readOnly.id, scope: 'HEAD' },
|
||
{ groupId: otherAll.id, scope: 'ALL' },
|
||
]);
|
||
const { s, rooms } = await socket(aTeacher),
|
||
deviceSocket = await socket(aDevice);
|
||
assert(rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'homework')));
|
||
assert(!rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
|
||
assert(!rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'grade')));
|
||
assert(SOCKET_CHANNELS.every((ch) => deviceSocket.rooms.includes(SOCKET_ROOMS.classChannel(A.id, ch))));
|
||
const join = await new Promise<{ ok: boolean }>((resolve) =>
|
||
s.emit('class:join', { classId: B.id, channels: ['homework'] }, resolve),
|
||
);
|
||
assert.equal(join.ok, false);
|
||
const seen: string[] = [];
|
||
s.on(SOCKET_EVENTS.gradeUpdated, () => seen.push('grade'));
|
||
s.on(SOCKET_EVENTS.homeworkNew, () => seen.push('homework'));
|
||
emitToClass(A.id, SOCKET_EVENTS.gradeUpdated, { classId: A.id });
|
||
emitToClass(B.id, SOCKET_EVENTS.homeworkNew, { id: 'foreign' });
|
||
emitToClass(A.id, SOCKET_EVENTS.homeworkNew, { id: 'own' });
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
assert.deepEqual(seen, ['homework']);
|
||
check('实时按读域与范围订阅,跨班 join 和跨域事件均拒绝');
|
||
const stale = signToken(admin);
|
||
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'TEACHER' } });
|
||
const staleSocket = connect(origin, {
|
||
auth: { token: stale },
|
||
transports: ['websocket'],
|
||
reconnection: false,
|
||
});
|
||
sockets.push(staleSocket);
|
||
const staleInfo = await new Promise<{ role: string }>((resolve) => staleSocket.once('connected', resolve));
|
||
assert.equal(staleInfo.role, 'TEACHER');
|
||
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'ADMIN' } });
|
||
check('Socket 与 HTTP 共用数据库身份加载,旧管理员 JWT 不保留角色');
|
||
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||
const legacy = structuredClone(snapshot);
|
||
for (const g of legacy.data.PermissionGroup)
|
||
if (g.id === readOnly.id) {
|
||
g.scope = 'SELF_CLASS';
|
||
g.scopeValue = 'HEAD';
|
||
}
|
||
for (const a of legacy.data.UserPermissionGroup) {
|
||
delete a.scope;
|
||
delete a.scopeValue;
|
||
}
|
||
await restoreSnapshot(prisma, 'sqlite', legacy);
|
||
assert.equal(
|
||
(
|
||
await prisma.userPermissionGroup.findFirstOrThrow({
|
||
where: { userId: aTeacher.sub, groupId: readOnly.id },
|
||
})
|
||
).scope,
|
||
'HEAD',
|
||
);
|
||
assert.equal(
|
||
(await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } })).scope,
|
||
'TEACHING',
|
||
);
|
||
assert.throws(() => parsePermissionScope('SELF_CLASS' as never, ''));
|
||
check('旧快照按模板回填分配、默认模板归一化、运行时拒绝旧范围枚举');
|
||
check(`授权攻击矩阵完成,夹具 ${studentA.id} / ${mathA.id} 均位于临时库`);
|
||
} finally {
|
||
for (const s of sockets) s.disconnect();
|
||
await new Promise<void>((resolve) => realtime.close(() => resolve()));
|
||
server.closeAllConnections();
|
||
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||
await prisma.$disconnect();
|
||
}
|