fix: close permission review gaps
fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'reflect-metadata';
|
||||
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
|
||||
describe('OrganizationsController permissions', () => {
|
||||
it('allows student editors to use the options endpoint without full entity exposure', () => {
|
||||
expect(
|
||||
Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOptions),
|
||||
).toEqual(['organization:view', 'student:create', 'student:edit']);
|
||||
});
|
||||
|
||||
it('keeps the full entity list restricted to organization viewers only', () => {
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findAll)).toEqual([
|
||||
'organization:view',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps organization detail restricted to organization viewers', () => {
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOne)).toEqual([
|
||||
'organization:view',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ export class OrganizationsController {
|
||||
private logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Get('options')
|
||||
@RequirePermission('organization:view', 'student:create', 'student:edit')
|
||||
findOptions() {
|
||||
return this.service.findOptions();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('organization:view')
|
||||
findAll(
|
||||
|
||||
@@ -27,4 +27,21 @@ describe('OrganizationsService — host organization rules', () => {
|
||||
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('findOptions returns only id, name, isHost for active organizations', async () => {
|
||||
const orgs = [
|
||||
{ id: 1, name: '本机构', isHost: true },
|
||||
{ id: 2, name: '分校', isHost: false },
|
||||
];
|
||||
repo.find.mockResolvedValue(orgs as Organization[]);
|
||||
|
||||
const result = await service.findOptions();
|
||||
|
||||
expect(repo.find).toHaveBeenCalledWith({
|
||||
select: ['id', 'name', 'isHost'],
|
||||
where: { status: 'active' },
|
||||
order: { isHost: 'DESC', name: 'ASC' },
|
||||
});
|
||||
expect(result).toEqual(orgs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,14 @@ export class OrganizationsService {
|
||||
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
|
||||
}
|
||||
|
||||
async findOptions() {
|
||||
return this.repo.find({
|
||||
select: ['id', 'name', 'isHost'] as const,
|
||||
where: { status: 'active' },
|
||||
order: { isHost: 'DESC' as const, name: 'ASC' as const },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const organization = await this.repo.findOne({ where: { id } });
|
||||
if (!organization) throw new NotFoundException('机构不存在');
|
||||
|
||||
@@ -33,9 +33,8 @@ export class CreateStudentDto {
|
||||
@IsString()
|
||||
emergencyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
organizationId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -43,21 +43,6 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults a new student to the active host organization when none is supplied', async () => {
|
||||
const repo = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 1 })),
|
||||
};
|
||||
const organizationRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }),
|
||||
};
|
||||
|
||||
await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual(
|
||||
expect.objectContaining({ organizationId: 7 }),
|
||||
);
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 }));
|
||||
});
|
||||
|
||||
it('returns not found for a missing student', async () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||
@@ -65,13 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
||||
|
||||
it('builds export archive maps from profile and result rows', async () => {
|
||||
const profileRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
studentId: 1,
|
||||
targetCollege: '北京大学',
|
||||
collegeSchool: '北京职业技术学院',
|
||||
},
|
||||
]),
|
||||
find: jest.fn().mockResolvedValue([{
|
||||
studentId: 1,
|
||||
targetCollege: '北京大学',
|
||||
collegeSchool: '北京职业技术学院',
|
||||
}]),
|
||||
};
|
||||
const resultRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),
|
||||
|
||||
@@ -164,9 +164,8 @@ export class StudentsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateStudentDto) {
|
||||
const organizationId = dto.organizationId || (await this.getHostOrganizationId());
|
||||
await this.assertActiveOrganization(organizationId);
|
||||
return this.repo.save(this.repo.create({ ...dto, organizationId }));
|
||||
await this.assertActiveOrganization(dto.organizationId);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateStudentDto) {
|
||||
@@ -315,9 +314,7 @@ export class StudentsService {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeImportData(
|
||||
importData: StudentWorkbookImport | StudentImportRow[],
|
||||
): StudentWorkbookImport {
|
||||
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
|
||||
if (Array.isArray(importData)) {
|
||||
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
|
||||
}
|
||||
@@ -373,24 +370,18 @@ export class StudentsService {
|
||||
if (!phone) return imported;
|
||||
|
||||
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
||||
for (const enrollmentRow of data.enrollments.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
||||
if (!enrollment) continue;
|
||||
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
||||
imported++;
|
||||
}
|
||||
for (const examRow of data.examScores.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
for (const learningRow of data.learningRecords.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
||||
imported++;
|
||||
}
|
||||
@@ -399,9 +390,7 @@ export class StudentsService {
|
||||
}
|
||||
|
||||
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity =
|
||||
(await this.profileRepo.findOne({ where: { studentId } })) ||
|
||||
this.profileRepo.create({ studentId });
|
||||
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
|
||||
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
||||
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
||||
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
|
||||
@@ -414,12 +403,9 @@ export class StudentsService {
|
||||
}
|
||||
|
||||
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity =
|
||||
(await this.resultRepo.findOne({ where: { studentId } })) ||
|
||||
this.resultRepo.create({ studentId });
|
||||
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
|
||||
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
||||
if (row.professionalFinalScore !== undefined)
|
||||
entity.professionalFinalScore = row.professionalFinalScore;
|
||||
if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
|
||||
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
||||
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
||||
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
||||
@@ -637,9 +623,10 @@ export class StudentsService {
|
||||
|
||||
// ---- Filters ----
|
||||
if (query?.keyword) {
|
||||
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
|
||||
keyword: `%${query.keyword}%`,
|
||||
});
|
||||
qb.andWhere(
|
||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||
{ keyword: `%${query.keyword}%` },
|
||||
);
|
||||
}
|
||||
if (query?.organizationId) {
|
||||
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
||||
|
||||
Reference in New Issue
Block a user