forked from datawhale/whale-town-end
feat:实现管理员系统核心功能
- 添加管理员数据库管理控制器和服务 - 实现管理员操作日志记录系统 - 添加数据库异常处理过滤器 - 完善管理员权限验证和响应格式 - 添加全面的属性测试覆盖
This commit is contained in:
509
src/business/admin/operation_logging.property.spec.ts
Normal file
509
src/business/admin/operation_logging.property.spec.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 操作日志属性测试
|
||||
*
|
||||
* Property 11: 操作日志完整性
|
||||
*
|
||||
* Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证操作日志记录的完整性和准确性
|
||||
* - 确保敏感操作被正确记录
|
||||
* - 验证日志查询和统计功能
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建操作日志属性测试 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { AdminOperationLogController } from '../../controllers/admin_operation_log.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
PropertyTestAssertions,
|
||||
DEFAULT_PROPERTY_CONFIG
|
||||
} from './admin_property_test.base';
|
||||
|
||||
describe('Property Test: 操作日志功能', () => {
|
||||
let app: INestApplication;
|
||||
let module: TestingModule;
|
||||
let databaseController: AdminDatabaseController;
|
||||
let logController: AdminOperationLogController;
|
||||
let mockLogService: any;
|
||||
let logEntries: any[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
logEntries = [];
|
||||
|
||||
mockLogService = {
|
||||
createLog: jest.fn().mockImplementation((logData) => {
|
||||
const logEntry = {
|
||||
id: `log_${logEntries.length + 1}`,
|
||||
...logData,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
logEntries.push(logEntry);
|
||||
return Promise.resolve(logEntry);
|
||||
}),
|
||||
queryLogs: jest.fn().mockImplementation((filters, limit, offset) => {
|
||||
let filteredLogs = [...logEntries];
|
||||
|
||||
if (filters.operation_type) {
|
||||
filteredLogs = filteredLogs.filter(log => log.operation_type === filters.operation_type);
|
||||
}
|
||||
if (filters.admin_id) {
|
||||
filteredLogs = filteredLogs.filter(log => log.admin_id === filters.admin_id);
|
||||
}
|
||||
if (filters.entity_type) {
|
||||
filteredLogs = filteredLogs.filter(log => log.entity_type === filters.entity_type);
|
||||
}
|
||||
|
||||
const total = filteredLogs.length;
|
||||
const paginatedLogs = filteredLogs.slice(offset, offset + limit);
|
||||
|
||||
return Promise.resolve({ logs: paginatedLogs, total });
|
||||
}),
|
||||
getLogById: jest.fn().mockImplementation((id) => {
|
||||
const log = logEntries.find(entry => entry.id === id);
|
||||
return Promise.resolve(log || null);
|
||||
}),
|
||||
getStatistics: jest.fn().mockImplementation(() => {
|
||||
const stats = {
|
||||
totalOperations: logEntries.length,
|
||||
operationsByType: {},
|
||||
operationsByAdmin: {},
|
||||
recentActivity: logEntries.slice(-10)
|
||||
};
|
||||
|
||||
logEntries.forEach(log => {
|
||||
stats.operationsByType[log.operation_type] =
|
||||
(stats.operationsByType[log.operation_type] || 0) + 1;
|
||||
stats.operationsByAdmin[log.admin_id] =
|
||||
(stats.operationsByAdmin[log.admin_id] || 0) + 1;
|
||||
});
|
||||
|
||||
return Promise.resolve(stats);
|
||||
}),
|
||||
cleanupExpiredLogs: jest.fn().mockResolvedValue(0),
|
||||
getAdminOperationHistory: jest.fn().mockImplementation((adminId) => {
|
||||
const adminLogs = logEntries.filter(log => log.admin_id === adminId);
|
||||
return Promise.resolve(adminLogs);
|
||||
}),
|
||||
getSensitiveOperations: jest.fn().mockImplementation((limit, offset) => {
|
||||
const sensitiveOps = ['DELETE', 'BATCH_UPDATE', 'ADMIN_CREATE'];
|
||||
const sensitiveLogs = logEntries.filter(log =>
|
||||
sensitiveOps.includes(log.operation_type)
|
||||
);
|
||||
const total = sensitiveLogs.length;
|
||||
const paginatedLogs = sensitiveLogs.slice(offset, offset + limit);
|
||||
|
||||
return Promise.resolve({ logs: paginatedLogs, total });
|
||||
})
|
||||
};
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.test', '.env']
|
||||
})
|
||||
],
|
||||
controllers: [AdminDatabaseController, AdminOperationLogController],
|
||||
providers: [
|
||||
DatabaseManagementService,
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: mockLogService
|
||||
},
|
||||
{
|
||||
provide: AdminOperationLogInterceptor,
|
||||
useValue: {
|
||||
intercept: jest.fn().mockImplementation((context, next) => next.handle())
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation(() => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({ ...user, id: BigInt(1) });
|
||||
}),
|
||||
create: jest.fn().mockImplementation((userData) => {
|
||||
return Promise.resolve({ ...userData, id: BigInt(1) });
|
||||
}),
|
||||
update: jest.fn().mockImplementation((id, updateData) => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({ ...user, ...updateData, id });
|
||||
}),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'IUserProfilesService',
|
||||
useValue: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue({ id: BigInt(1) }),
|
||||
create: jest.fn().mockResolvedValue({ id: BigInt(1) }),
|
||||
update: jest.fn().mockResolvedValue({ id: BigInt(1) }),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
findByMap: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: {
|
||||
findMany: jest.fn().mockResolvedValue({ accounts: [] }),
|
||||
findById: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
create: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
getStatusStatistics: jest.fn().mockResolvedValue({
|
||||
active: 0, inactive: 0, suspended: 0, error: 0, total: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.useGlobalFilters(new AdminDatabaseExceptionFilter());
|
||||
await app.init();
|
||||
|
||||
databaseController = module.get<AdminDatabaseController>(AdminDatabaseController);
|
||||
logController = module.get<AdminOperationLogController>(AdminOperationLogController);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
logEntries.length = 0; // 清空日志记录
|
||||
});
|
||||
|
||||
describe('Property 11: 操作日志完整性', () => {
|
||||
it('所有CRUD操作都应该生成日志记录', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'CRUD操作日志记录完整性',
|
||||
() => PropertyTestGenerators.generateUser(),
|
||||
async (userData) => {
|
||||
const userWithStatus = { ...userData, status: UserStatus.ACTIVE };
|
||||
|
||||
// 执行创建操作
|
||||
await databaseController.createUser(userWithStatus);
|
||||
|
||||
// 执行读取操作
|
||||
await databaseController.getUserById('1');
|
||||
|
||||
// 执行更新操作
|
||||
await databaseController.updateUser('1', { nickname: 'Updated Name' });
|
||||
|
||||
// 执行删除操作
|
||||
await databaseController.deleteUser('1');
|
||||
|
||||
// 验证日志记录
|
||||
expect(mockLogService.createLog).toHaveBeenCalledTimes(4);
|
||||
|
||||
// 验证日志内容包含必要信息
|
||||
const createLogCall = mockLogService.createLog.mock.calls.find(call =>
|
||||
call[0].operation_type === 'CREATE'
|
||||
);
|
||||
const updateLogCall = mockLogService.createLog.mock.calls.find(call =>
|
||||
call[0].operation_type === 'UPDATE'
|
||||
);
|
||||
const deleteLogCall = mockLogService.createLog.mock.calls.find(call =>
|
||||
call[0].operation_type === 'DELETE'
|
||||
);
|
||||
|
||||
expect(createLogCall).toBeDefined();
|
||||
expect(updateLogCall).toBeDefined();
|
||||
expect(deleteLogCall).toBeDefined();
|
||||
|
||||
// 验证日志包含实体信息
|
||||
expect(createLogCall[0].entity_type).toBe('User');
|
||||
expect(updateLogCall[0].entity_type).toBe('User');
|
||||
expect(deleteLogCall[0].entity_type).toBe('User');
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it('日志记录应该包含完整的操作上下文', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'日志上下文完整性',
|
||||
() => ({
|
||||
user: PropertyTestGenerators.generateUser(),
|
||||
adminId: `admin_${Math.floor(Math.random() * 1000)}`,
|
||||
ipAddress: `192.168.1.${Math.floor(Math.random() * 255)}`,
|
||||
userAgent: 'Test-Agent/1.0'
|
||||
}),
|
||||
async ({ user, adminId, ipAddress, userAgent }) => {
|
||||
const userWithStatus = { ...user, status: UserStatus.ACTIVE };
|
||||
|
||||
// 模拟带上下文的操作
|
||||
await databaseController.createUser(userWithStatus);
|
||||
|
||||
// 验证日志记录包含上下文信息
|
||||
expect(mockLogService.createLog).toHaveBeenCalled();
|
||||
const logCall = mockLogService.createLog.mock.calls[0][0];
|
||||
|
||||
expect(logCall).toHaveProperty('operation_type');
|
||||
expect(logCall).toHaveProperty('entity_type');
|
||||
expect(logCall).toHaveProperty('entity_id');
|
||||
expect(logCall).toHaveProperty('admin_id');
|
||||
expect(logCall).toHaveProperty('operation_details');
|
||||
expect(logCall).toHaveProperty('timestamp');
|
||||
|
||||
// 验证时间戳格式
|
||||
expect(new Date(logCall.timestamp)).toBeInstanceOf(Date);
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 25 }
|
||||
);
|
||||
});
|
||||
|
||||
it('敏感操作应该记录详细的前后状态', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'敏感操作详细日志',
|
||||
() => ({
|
||||
accounts: Array.from({ length: Math.floor(Math.random() * 5) + 2 },
|
||||
() => PropertyTestGenerators.generateZulipAccount()),
|
||||
targetStatus: ['active', 'inactive', 'suspended'][Math.floor(Math.random() * 3)]
|
||||
}),
|
||||
async ({ accounts, targetStatus }) => {
|
||||
const accountIds = accounts.map((_, i) => `account_${i + 1}`);
|
||||
|
||||
// 执行批量更新操作(敏感操作)
|
||||
await databaseController.batchUpdateZulipAccountStatus({
|
||||
ids: accountIds,
|
||||
status: targetStatus as any,
|
||||
reason: '测试批量更新'
|
||||
});
|
||||
|
||||
// 验证敏感操作日志
|
||||
expect(mockLogService.createLog).toHaveBeenCalled();
|
||||
const logCall = mockLogService.createLog.mock.calls[0][0];
|
||||
|
||||
expect(logCall.operation_type).toBe('BATCH_UPDATE');
|
||||
expect(logCall.entity_type).toBe('ZulipAccount');
|
||||
expect(logCall.operation_details).toContain('reason');
|
||||
expect(logCall.operation_details).toContain(targetStatus);
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 15 }
|
||||
);
|
||||
});
|
||||
|
||||
it('日志查询应该支持多种过滤条件', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'日志查询过滤功能',
|
||||
() => {
|
||||
// 预先创建一些日志记录
|
||||
const operations = ['CREATE', 'UPDATE', 'DELETE', 'BATCH_UPDATE'];
|
||||
const entities = ['User', 'UserProfile', 'ZulipAccount'];
|
||||
const adminIds = ['admin1', 'admin2', 'admin3'];
|
||||
|
||||
return {
|
||||
operation_type: operations[Math.floor(Math.random() * operations.length)],
|
||||
entity_type: entities[Math.floor(Math.random() * entities.length)],
|
||||
admin_id: adminIds[Math.floor(Math.random() * adminIds.length)]
|
||||
};
|
||||
},
|
||||
async (filters) => {
|
||||
// 预先添加一些测试日志
|
||||
await mockLogService.createLog({
|
||||
operation_type: filters.operation_type,
|
||||
entity_type: filters.entity_type,
|
||||
admin_id: filters.admin_id,
|
||||
entity_id: '1',
|
||||
operation_details: JSON.stringify({ test: true }),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 查询日志
|
||||
const response = await logController.queryLogs(
|
||||
filters.operation_type,
|
||||
filters.entity_type,
|
||||
filters.admin_id,
|
||||
undefined,
|
||||
undefined,
|
||||
'20', // 修复:传递字符串而不是数字
|
||||
0
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
PropertyTestAssertions.assertListResponseFormat(response);
|
||||
|
||||
// 验证过滤结果
|
||||
response.data.items.forEach((log: any) => {
|
||||
expect(log.operation_type).toBe(filters.operation_type);
|
||||
expect(log.entity_type).toBe(filters.entity_type);
|
||||
expect(log.admin_id).toBe(filters.admin_id);
|
||||
});
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it('日志统计应该准确反映操作情况', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'日志统计准确性',
|
||||
() => {
|
||||
const operations = Array.from({ length: Math.floor(Math.random() * 10) + 5 }, () => ({
|
||||
operation_type: ['CREATE', 'UPDATE', 'DELETE'][Math.floor(Math.random() * 3)],
|
||||
entity_type: ['User', 'UserProfile'][Math.floor(Math.random() * 2)],
|
||||
admin_id: `admin_${Math.floor(Math.random() * 3) + 1}`
|
||||
}));
|
||||
|
||||
return { operations };
|
||||
},
|
||||
async ({ operations }) => {
|
||||
// 创建测试日志
|
||||
for (const op of operations) {
|
||||
await mockLogService.createLog({
|
||||
...op,
|
||||
entity_id: '1',
|
||||
operation_details: JSON.stringify({}),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
const response = await logController.getStatistics();
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.totalOperations).toBe(operations.length);
|
||||
expect(response.data.operationsByType).toBeDefined();
|
||||
expect(response.data.operationsByAdmin).toBeDefined();
|
||||
|
||||
// 验证统计数据准确性
|
||||
const expectedByType = {};
|
||||
const expectedByAdmin = {};
|
||||
|
||||
operations.forEach(op => {
|
||||
expectedByType[op.operation_type] = (expectedByType[op.operation_type] || 0) + 1;
|
||||
expectedByAdmin[op.admin_id] = (expectedByAdmin[op.admin_id] || 0) + 1;
|
||||
});
|
||||
|
||||
expect(response.data.operationsByType).toEqual(expectedByType);
|
||||
expect(response.data.operationsByAdmin).toEqual(expectedByAdmin);
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 15 }
|
||||
);
|
||||
});
|
||||
|
||||
it('敏感操作查询应该正确识别和过滤', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'敏感操作识别准确性',
|
||||
() => {
|
||||
const allOperations = ['CREATE', 'READ', 'UPDATE', 'DELETE', 'BATCH_UPDATE', 'ADMIN_CREATE'];
|
||||
const operations = Array.from({ length: Math.floor(Math.random() * 8) + 3 }, () =>
|
||||
allOperations[Math.floor(Math.random() * allOperations.length)]
|
||||
);
|
||||
|
||||
return { operations };
|
||||
},
|
||||
async ({ operations }) => {
|
||||
// 创建测试日志
|
||||
for (const op of operations) {
|
||||
await mockLogService.createLog({
|
||||
operation_type: op,
|
||||
entity_type: 'User',
|
||||
admin_id: 'admin1',
|
||||
entity_id: '1',
|
||||
operation_details: JSON.stringify({}),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// 查询敏感操作
|
||||
const response = await logController.getSensitiveOperations(20, 0);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
PropertyTestAssertions.assertListResponseFormat(response);
|
||||
|
||||
// 验证只返回敏感操作
|
||||
const sensitiveOps = ['DELETE', 'BATCH_UPDATE', 'ADMIN_CREATE'];
|
||||
const expectedSensitiveCount = operations.filter(op =>
|
||||
sensitiveOps.includes(op)
|
||||
).length;
|
||||
|
||||
expect(response.data.total).toBe(expectedSensitiveCount);
|
||||
|
||||
response.data.items.forEach((log: any) => {
|
||||
expect(sensitiveOps).toContain(log.operation_type);
|
||||
});
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it('管理员操作历史应该完整记录', async () => {
|
||||
await PropertyTestRunner.runPropertyTest(
|
||||
'管理员操作历史完整性',
|
||||
() => {
|
||||
const adminId = `admin_${Math.floor(Math.random() * 100)}`;
|
||||
const operations = Array.from({ length: Math.floor(Math.random() * 5) + 2 }, () => ({
|
||||
operation_type: ['CREATE', 'UPDATE', 'DELETE'][Math.floor(Math.random() * 3)],
|
||||
entity_type: 'User',
|
||||
admin_id: adminId
|
||||
}));
|
||||
|
||||
return { adminId, operations };
|
||||
},
|
||||
async ({ adminId, operations }) => {
|
||||
// 创建该管理员的操作日志
|
||||
for (const op of operations) {
|
||||
await mockLogService.createLog({
|
||||
...op,
|
||||
entity_id: '1',
|
||||
operation_details: JSON.stringify({}),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// 创建其他管理员的操作日志(干扰数据)
|
||||
await mockLogService.createLog({
|
||||
operation_type: 'CREATE',
|
||||
entity_type: 'User',
|
||||
admin_id: 'other_admin',
|
||||
entity_id: '2',
|
||||
operation_details: JSON.stringify({}),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 查询特定管理员的操作历史
|
||||
const response = await logController.getAdminOperationHistory(adminId);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toHaveLength(operations.length);
|
||||
|
||||
// 验证所有返回的日志都属于指定管理员
|
||||
response.data.forEach((log: any) => {
|
||||
expect(log.admin_id).toBe(adminId);
|
||||
});
|
||||
},
|
||||
{ ...DEFAULT_PROPERTY_CONFIG, iterations: 15 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user