feat(M08-D): 补审计日志与系统配置
This commit is contained in:
@@ -42,6 +42,7 @@ import { DeviceCommandService } from '../dist/devices/device-command-service.js'
|
||||
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
||||
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -1795,6 +1796,49 @@ async function assertFranchiseManagement(pool, context) {
|
||||
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
|
||||
}
|
||||
|
||||
async function assertSystemOperations(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
|
||||
);
|
||||
const adminId = String(adminRows[0].id);
|
||||
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
||||
traceId: 'm08d-system-live', ip: '172.18.20.42', userAgent: 'M08-D system live test' };
|
||||
await pool.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'M08D_REDACTION_PROBE',
|
||||
'TENANT', ?, ?, ?, ?, ?)`,
|
||||
[context.tenantId, adminId, context.tenantId, actor.traceId, actor.ip, actor.userAgent,
|
||||
JSON.stringify({ orderId: 'safe-order', phone: '13800138000', nested: { apiKey: 'secret', safe: 'visible' } })]
|
||||
);
|
||||
const repository = new SystemOperationsRepository(pool);
|
||||
const logs = await repository.listAuditLogs({
|
||||
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'M08D_REDACTION_PROBE'
|
||||
});
|
||||
assert.equal(logs.total, 1);
|
||||
assert.equal(logs.items[0].tenantId, context.tenantId);
|
||||
assert.equal(logs.items[0].ip, '172.18.***.***');
|
||||
assert.equal(logs.items[0].metadata.phone, '[REDACTED]');
|
||||
assert.equal(logs.items[0].metadata.nested.apiKey, '[REDACTED]');
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081003');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
});
|
||||
const updatedLogs = await repository.listAuditLogs({
|
||||
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'TENANT_SYSTEM_CONFIG_UPDATED'
|
||||
});
|
||||
assert.equal(updatedLogs.total, 1);
|
||||
assert.equal(updatedLogs.items[0].actorId, adminId);
|
||||
}
|
||||
|
||||
async function assertDeviceTopology(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
@@ -2083,6 +2127,7 @@ try {
|
||||
await assertStoreRoomDomain(pool, loginContext);
|
||||
await assertContentManagement(pool, loginContext);
|
||||
await assertFranchiseManagement(pool, loginContext);
|
||||
await assertSystemOperations(pool, loginContext);
|
||||
await assertStoreDiscovery(pool, loginContext);
|
||||
await assertSceneAndWifiAccess(pool, loginContext);
|
||||
await assertPricingAndReservations(pool, loginContext);
|
||||
@@ -2173,6 +2218,10 @@ try {
|
||||
'versioned decoration publish and archive',
|
||||
'store advertisement delivery scope',
|
||||
'platform advertisement rejection',
|
||||
'franchise application idempotency and phone-free audit metadata',
|
||||
'franchise assignment and controlled follow-up status transition',
|
||||
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
|
||||
'system overview and audited tenant configuration update',
|
||||
'city fallback store filtering',
|
||||
'server-side distance sorting',
|
||||
'empty manual city result',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
||||
|
||||
const secret = 'test-only-system-operations-secret-32';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let auditQuery;
|
||||
let overviewTenantId;
|
||||
let updateCall;
|
||||
const repository = {
|
||||
async listAuditLogs(input) {
|
||||
auditQuery = input;
|
||||
return { items: [], total: 0, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async getSystemOverview(tenantId) {
|
||||
overviewTenantId = tenantId;
|
||||
return {
|
||||
tenant: { id: tenantId, code: 'demo', name: '演示租户', status: 'ACTIVE', timezone: 'Asia/Shanghai' },
|
||||
counts: { storeCount: 1, userCount: 2, activeSessionCount: 1, appBindingCount: 1, auditTodayCount: 3 },
|
||||
latestMigration: { version: '2026081003', name: 'm08d_franchise_leads' }
|
||||
};
|
||||
},
|
||||
async updateTenant(actor, tenantId, input) {
|
||||
updateCall = { actor, tenantId, input };
|
||||
return { tenantId, updated: true };
|
||||
}
|
||||
};
|
||||
const authRepository = {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
|
||||
nickname: '管理员', avatarUrl: '', phone: '' }
|
||||
};
|
||||
}
|
||||
};
|
||||
const tenantAccess = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
|
||||
const app = await buildApp({ systemOperations: {
|
||||
repository, authRepository,
|
||||
accessControl: { async getAccessProfile() { return tenantAccess; } },
|
||||
jwtSecret: secret
|
||||
} });
|
||||
const auth = { authorization: `Bearer ${token}` };
|
||||
|
||||
const listed = await app.inject({ method: 'GET',
|
||||
url: '/admin-api/audit-logs?action=ORDER_CREATED&resourceType=ORDER&page=2&pageSize=10', headers: auth });
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual({ tenantId: auditQuery.tenantId, action: auditQuery.action,
|
||||
resourceType: auditQuery.resourceType, page: auditQuery.page },
|
||||
{ tenantId: '7', action: 'ORDER_CREATED', resourceType: 'ORDER', page: 2 });
|
||||
assert.ok(auditQuery.from instanceof Date);
|
||||
assert.ok(auditQuery.to instanceof Date);
|
||||
|
||||
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/audit-logs?tenantId=8', headers: auth });
|
||||
assert.equal(crossTenant.statusCode, 403);
|
||||
const invalidRange = await app.inject({ method: 'GET',
|
||||
url: '/admin-api/audit-logs?from=2025-01-01&to=2026-08-01', headers: auth });
|
||||
assert.equal(invalidRange.statusCode, 400);
|
||||
|
||||
const overview = await app.inject({ method: 'GET', url: '/admin-api/system/overview', headers: auth });
|
||||
assert.equal(overview.statusCode, 200);
|
||||
assert.equal(overviewTenantId, '7');
|
||||
assert.equal(overview.json().data.counts.auditTodayCount, 3);
|
||||
|
||||
const updated = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||
payload: { name: '新租户名称', timezone: 'Asia/Shanghai' } });
|
||||
assert.equal(updated.statusCode, 200);
|
||||
assert.deepEqual({ tenantId: updateCall.tenantId, name: updateCall.input.name,
|
||||
timezone: updateCall.input.timezone },
|
||||
{ tenantId: '7', name: '新租户名称', timezone: 'Asia/Shanghai' });
|
||||
const invalidTimezone = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||
payload: { name: '新租户名称', timezone: 'Mars/Olympus' } });
|
||||
assert.equal(invalidTimezone.statusCode, 400);
|
||||
const tenantStatusChange = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||
payload: { name: '新租户名称', timezone: 'Asia/Shanghai', status: 'DISABLED' } });
|
||||
assert.equal(tenantStatusChange.statusCode, 403);
|
||||
assert.equal(tenantStatusChange.json().code, 'SYSTEM_STATUS_FORBIDDEN');
|
||||
await app.close();
|
||||
|
||||
const platformApp = await buildApp({ systemOperations: {
|
||||
repository, authRepository,
|
||||
accessControl: { async getAccessProfile() {
|
||||
return { roles: ['PLATFORM_ADMIN'], capabilities: ['platform.manage'], storeIds: [] };
|
||||
} }, jwtSecret: secret
|
||||
} });
|
||||
const platformUpdate = await platformApp.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||
payload: { tenantId: '8', name: '平台目标租户', timezone: 'UTC', status: 'DISABLED' } });
|
||||
assert.equal(platformUpdate.statusCode, 200);
|
||||
assert.equal(updateCall.tenantId, '8');
|
||||
assert.equal(updateCall.input.status, 'DISABLED');
|
||||
await platformApp.close();
|
||||
|
||||
const sensitivePool = {
|
||||
async execute(sql) {
|
||||
if (sql.includes('COUNT(*)')) return [[{ total: 1 }], []];
|
||||
return [[{
|
||||
id: '99', tenantId: '7', actorType: 'USER', actorId: '21', actorName: '管理员',
|
||||
action: 'CONFIG_UPDATED', resourceType: 'TENANT', resourceId: '7', traceId: 'trace-99',
|
||||
ip: '192.168.10.22', userAgent: 'test',
|
||||
metadata: JSON.stringify({ orderId: '88', phone: '13800138000',
|
||||
nested: { apiKey: 'secret-key', safe: 'visible' } }), createdAt: new Date()
|
||||
}], []];
|
||||
}
|
||||
};
|
||||
const sanitized = await new SystemOperationsRepository(sensitivePool).listAuditLogs({
|
||||
tenantId: '7', page: 1, pageSize: 20
|
||||
});
|
||||
assert.equal(sanitized.items[0].ip, '192.168.***.***');
|
||||
assert.equal(sanitized.items[0].metadata.phone, '[REDACTED]');
|
||||
assert.equal(sanitized.items[0].metadata.nested.apiKey, '[REDACTED]');
|
||||
assert.equal(sanitized.items[0].metadata.nested.safe, 'visible');
|
||||
|
||||
console.log('PASS: M08-D tenant-scoped audit logs, metadata redaction and system settings routes are present.');
|
||||
Reference in New Issue
Block a user