fix: close permission review gaps

fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
This commit is contained in:
2026-07-23 12:23:33 +08:00
parent c98d37307e
commit a7a7af1667
17 changed files with 391 additions and 227 deletions

View File

@@ -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',
]);
});
});

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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('机构不存在');