forked from wangziqi/gongxue-base
feat: add tenant class student scopes
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user