feat: add tenant class student scopes

This commit is contained in:
Codex
2026-06-29 00:50:22 +08:00
parent b262e87af9
commit 61240c5833
12 changed files with 1172 additions and 25 deletions

View File

@@ -13,6 +13,8 @@ const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000-
const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103';
const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
const TENANT_TEACHER_USER_ID = '00000000-0000-0000-0000-000000000106';
const SECOND_STUDENT_USER_ID = '00000000-0000-0000-0000-000000000107';
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
@@ -40,6 +42,8 @@ const ids = {
video: '00000000-0000-0000-0000-000000000821',
quotaVideo: '00000000-0000-0000-0000-000000000824',
scorelineSchool: '00000000-0000-0000-0000-000000000831',
tenantClass: '00000000-0000-0000-0000-000000000851',
tenantClassOther: '00000000-0000-0000-0000-000000000852',
};
const paymentFixture = (() => {
@@ -2556,6 +2560,208 @@ async function testTenantMemberPermissionsAndAudit() {
assert.equal(partnerAuditDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant audit logs must be tenant isolated');
}
async function testTenantClassStudentScopes() {
const permissionMatrix = await request('/api/tenant-admin/permissions', {
userId: TENANT_ADMIN_USER_ID,
});
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'classes:write'), 'permission matrix should expose class write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'students:read'), 'permission matrix should expose student read permission');
const createdClass = await request('/api/tenant-admin/classes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantClass,
regionId: ids.region,
code: 'integration-main',
name: '集成测试主班级',
description: '教师和学生范围权限测试',
sortOrder: 1,
metadata: { stage: 'integration' },
},
});
assert.equal(createdClass.item?.id, ids.tenantClass, 'tenant admin should upsert class');
const otherClass = await request('/api/tenant-admin/classes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantClassOther,
regionId: ids.region,
code: 'integration-other',
name: '集成测试其他班级',
sortOrder: 2,
},
});
assert.equal(otherClass.item?.id, ids.tenantClassOther, 'tenant admin should upsert another class');
const teacherTemplate = await request('/api/tenant-admin/role-templates', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'teacher-class-scope',
name: '教师班级范围模板',
baseRole: 'teacher',
permissions: {
'classes:read': true,
'students:read': true,
},
menuPermissions: {
teachers: true,
students: true,
},
fieldPermissions: {
'student.phone': false,
},
dataScope: {
mode: 'classes',
},
},
});
assert.equal(teacherTemplate.item?.code, 'teacher-class-scope', 'tenant admin should create teacher scope template');
const teacher = await request('/api/tenant-admin/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: TENANT_TEACHER_USER_ID,
username: 'integration_teacher',
phone: '13800000015',
name: 'Integration Teacher',
role: 'teacher',
roleTemplateId: teacherTemplate.item.id,
status: 'active',
permissions: {},
},
});
assert.equal(teacher.item?.role, 'teacher', 'tenant admin should create teacher member');
const student = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: USER_ID,
username: 'smoke_student',
phone: '13800000000',
name: 'Smoke Student',
regionId: ids.region,
status: 'active',
},
});
assert.equal(student.item?.userId, USER_ID, 'tenant admin should upsert student profile');
const secondStudent = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: SECOND_STUDENT_USER_ID,
username: 'integration_second_student',
phone: '13800000016',
name: 'Integration Second Student',
regionId: ids.region,
status: 'active',
},
});
assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student');
const teacherAssignment = await request('/api/tenant-admin/classes/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
classId: ids.tenantClass,
userId: TENANT_TEACHER_USER_ID,
memberType: 'teacher',
status: 'active',
},
});
assert.equal(teacherAssignment.item?.memberType, 'teacher', 'tenant admin should assign teacher to class');
const studentAssignment = await request('/api/tenant-admin/classes/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
classId: ids.tenantClass,
userId: USER_ID,
memberType: 'student',
status: 'active',
},
});
assert.equal(studentAssignment.item?.memberType, 'student', 'tenant admin should assign student to class');
const otherStudentAssignment = await request('/api/tenant-admin/classes/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
classId: ids.tenantClassOther,
userId: SECOND_STUDENT_USER_ID,
memberType: 'student',
status: 'active',
},
});
assert.equal(otherStudentAssignment.item?.classId, ids.tenantClassOther, 'tenant admin should assign second student to another class');
const adminClasses = await request('/api/tenant-admin/classes', {
userId: TENANT_ADMIN_USER_ID,
});
assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClass && item.studentCount >= 1), 'admin should see main class student count');
assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClassOther), 'admin should see other class');
const teacherClasses = await request('/api/tenant-admin/classes', {
userId: TENANT_TEACHER_USER_ID,
});
assert.equal(teacherClasses.scoped, true, 'teacher class list should be scoped');
assert.ok(teacherClasses.items?.some(item => item.id === ids.tenantClass), 'teacher should see assigned class');
assert.ok(!teacherClasses.items?.some(item => item.id === ids.tenantClassOther), 'teacher should not see unassigned class');
const teacherStudents = await request('/api/tenant-admin/students', {
userId: TENANT_TEACHER_USER_ID,
});
assert.equal(teacherStudents.scoped, true, 'teacher student list should be scoped');
assert.ok(teacherStudents.items?.some(item => item.userId === USER_ID), 'teacher should see student in assigned class');
assert.ok(!teacherStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'teacher should not see student in unassigned class');
const visibleStudent = teacherStudents.items?.find(item => item.userId === USER_ID);
assert.equal(visibleStudent?.phone, null, 'teacher role template should mask student phone');
const teacherOtherClassDenied = await request('/api/tenant-admin/classes/members', {
userId: TENANT_TEACHER_USER_ID,
query: { classId: ids.tenantClassOther },
expectStatus: 403,
});
assert.equal(teacherOtherClassDenied.code, 'CLASS_SCOPE_REQUIRED', 'teacher should not read another class members');
const adminStudents = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { classId: ids.tenantClassOther },
});
assert.ok(adminStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'tenant admin should filter students by class');
const studentDenied = await request('/api/tenant-admin/students', {
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant admin student list');
const partnerClassDenied = await request('/api/tenant-admin/classes/members', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
classId: ids.tenantClass,
userId: USER_ID,
memberType: 'student',
},
expectStatus: 403,
});
assert.equal(partnerClassDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not assign another tenant class');
const auditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
query: { action: 'tenant.class', limit: 50 },
});
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class.upserted'), 'audit logs should include class upsert');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert');
}
async function testReferralAndCrmGrowth() {
const salesMember = await request('/api/tenant-admin/members', {
userId: TENANT_ADMIN_USER_ID,
@@ -2751,6 +2957,7 @@ async function main() {
await check('tenant content assets and imports', testTenantContentAssetsAndImports);
await check('tenant admin operations', testTenantAdminOps);
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
await check('tenant class and student scopes', testTenantClassStudentScopes);
await check('referral and CRM growth', testReferralAndCrmGrowth);
console.log('API integration tests complete.');

View File

@@ -15,6 +15,8 @@ const ids = {
tenantOperatorUser: '00000000-0000-0000-0000-000000000103',
tenantSalesUser: '00000000-0000-0000-0000-000000000104',
tenantAgentUser: '00000000-0000-0000-0000-000000000105',
tenantTeacherUser: '00000000-0000-0000-0000-000000000106',
secondStudentUser: '00000000-0000-0000-0000-000000000107',
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
@@ -50,6 +52,8 @@ const ids = {
scorelineField: '00000000-0000-0000-0000-000000000833',
scorelineRecord: '00000000-0000-0000-0000-000000000834',
recentPractice: '00000000-0000-0000-0000-000000000841',
tenantClass: '00000000-0000-0000-0000-000000000851',
tenantClassOther: '00000000-0000-0000-0000-000000000852',
partnerTenant: '00000000-0000-0000-0000-000000000901',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
partnerInvoice: '00000000-0000-0000-0000-000000000903',
@@ -208,11 +212,11 @@ async function main() {
delete from public.referral_team_edges
where tenant_id = $1
and (
member_user_id in ($2::uuid, $3::uuid, $4::uuid)
or leader_user_id in ($2::uuid, $3::uuid, $4::uuid)
member_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
or leader_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
)
`,
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser, ids.tenantTeacherUser],
);
await client.query(
@@ -269,7 +273,9 @@ async function main() {
values
($1, $2, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
($3, null, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb),
($5, null, 'smoke_teacher', '13800000015', 'Smoke Teacher', 'teacher', '{"source":"smoke-seed"}'::jsonb),
($6, null, 'smoke_second_student', '13800000016', 'Smoke Second Student', 'student', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
@@ -278,7 +284,14 @@ async function main() {
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantOperatorUser, ids.authTenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
[
ids.tenantOperatorUser,
ids.authTenantOperatorUser,
ids.tenantSalesUser,
ids.tenantAgentUser,
ids.tenantTeacherUser,
ids.secondStudentUser,
],
);
await client.query(
@@ -335,13 +348,15 @@ async function main() {
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values
($1, $2, 'tenant_operator', 'active', '{"marketing:read":true}'::jsonb),
($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb)
($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb),
($1, $4, 'teacher', 'active', '{"classes:read":true,"students:read":true}'::jsonb),
($1, $5, 'student', 'active', '{}'::jsonb)
on conflict (tenant_id, user_id, role)
do update set status = 'active',
permissions = excluded.permissions,
updated_at = now()
`,
[tenantId, ids.tenantOperatorUser, ids.platformAdminUser],
[tenantId, ids.tenantOperatorUser, ids.platformAdminUser, ids.tenantTeacherUser, ids.secondStudentUser],
);
await client.query(
@@ -353,6 +368,15 @@ async function main() {
[tenantId, ids.user],
);
await client.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values ($1, $2, '{"totalAnswered":0,"correctCount":0,"wrongCount":0,"studyDays":1}'::jsonb, '{}'::jsonb)
on conflict (tenant_id, user_id) do nothing
`,
[tenantId, ids.secondStudentUser],
);
await client.query(
`
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
@@ -1028,6 +1052,55 @@ async function main() {
[ids.recentPractice, tenantId, ids.user],
);
await client.query(
`
insert into public.tenant_classes (
id, tenant_id, region_id, legacy_id, code, name, description,
status, sort_order, metadata, created_by, updated_by
)
values
($1, $3, $4, 'smoke-class-main', 'smoke-main', '烟测主班级', '用于教师学生范围权限测试', 'active', 1, '{"source":"smoke-seed"}'::jsonb, $5, $5),
($2, $3, $4, 'smoke-class-other', 'smoke-other', '烟测其他班级', '用于验证非负责班级不可见', 'active', 2, '{"source":"smoke-seed"}'::jsonb, $5, $5)
on conflict (id)
do update set region_id = excluded.region_id,
code = excluded.code,
name = excluded.name,
description = excluded.description,
status = excluded.status,
sort_order = excluded.sort_order,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
`,
[ids.tenantClass, ids.tenantClassOther, tenantId, ids.region, ids.tenantAdminUser],
);
await client.query(
`
insert into public.tenant_class_members (
tenant_id, class_id, user_id, member_type, status, metadata, created_by, updated_by
)
values
($1, $2, $4, 'teacher', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6),
($1, $2, $5, 'student', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6),
($1, $3, $7, 'student', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6)
on conflict (tenant_id, class_id, user_id, member_type)
do update set status = excluded.status,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
`,
[
tenantId,
ids.tenantClass,
ids.tenantClassOther,
ids.tenantTeacherUser,
ids.user,
ids.tenantAdminUser,
ids.secondStudentUser,
],
);
await client.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)