feat: Implement centralized audit logging admin interface (refs #10)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s

- Add audit_logs table with categories, severities, and indexes
- Create AuditLogService and AuditLogRepository
- Add REST API endpoints for viewing and exporting logs
- Wire audit logging into auth, vehicles, admin, and backup features
- Add desktop AdminLogsPage with filters and CSV export
- Add mobile AdminLogsMobileScreen with card layout
- Implement 90-day retention cleanup job
- Remove old AuditLogPanel from AdminCatalogPage

Security fixes:
- Escape LIKE special characters to prevent pattern injection
- Limit CSV export to 5000 records to prevent memory exhaustion
- Add truncation warning headers for large exports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 11:09:09 -06:00
parent 8c7de98a9a
commit c98211f4a2
30 changed files with 2897 additions and 11 deletions

View File

@@ -0,0 +1,207 @@
/**
* @ai-summary Integration tests for AuditLogService
* @ai-context Tests log creation, search, filtering, and cleanup
*/
import { Pool } from 'pg';
import { appConfig } from '../../../core/config/config-loader';
import { AuditLogService } from '../domain/audit-log.service';
import { AuditLogRepository } from '../data/audit-log.repository';
describe('AuditLogService', () => {
let pool: Pool;
let repository: AuditLogRepository;
let service: AuditLogService;
const createdIds: string[] = [];
beforeAll(async () => {
pool = new Pool({
connectionString: appConfig.getDatabaseUrl(),
});
repository = new AuditLogRepository(pool);
service = new AuditLogService(repository);
});
afterAll(async () => {
// Cleanup test data
if (createdIds.length > 0) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]);
}
await pool.end();
});
describe('log()', () => {
it('should create log entry with all fields', async () => {
const entry = await service.log(
'auth',
'info',
'user-123',
'User logged in',
'session',
'session-456',
{ ip: '192.168.1.1', browser: 'Chrome' }
);
createdIds.push(entry.id);
expect(entry.id).toBeDefined();
expect(entry.category).toBe('auth');
expect(entry.severity).toBe('info');
expect(entry.userId).toBe('user-123');
expect(entry.action).toBe('User logged in');
expect(entry.resourceType).toBe('session');
expect(entry.resourceId).toBe('session-456');
expect(entry.details).toEqual({ ip: '192.168.1.1', browser: 'Chrome' });
expect(entry.createdAt).toBeInstanceOf(Date);
});
it('should create log entry with null userId for system actions', async () => {
const entry = await service.log(
'system',
'info',
null,
'Scheduled backup started'
);
createdIds.push(entry.id);
expect(entry.id).toBeDefined();
expect(entry.category).toBe('system');
expect(entry.userId).toBeNull();
});
it('should throw error for invalid category', async () => {
await expect(
service.log(
'invalid' as any,
'info',
'user-123',
'Test action'
)
).rejects.toThrow('Invalid audit log category');
});
it('should throw error for invalid severity', async () => {
await expect(
service.log(
'auth',
'invalid' as any,
'user-123',
'Test action'
)
).rejects.toThrow('Invalid audit log severity');
});
});
describe('convenience methods', () => {
it('info() should create info-level log', async () => {
const entry = await service.info('vehicle', 'user-123', 'Vehicle created');
createdIds.push(entry.id);
expect(entry.severity).toBe('info');
});
it('warning() should create warning-level log', async () => {
const entry = await service.warning('user', 'user-123', 'Password reset requested');
createdIds.push(entry.id);
expect(entry.severity).toBe('warning');
});
it('error() should create error-level log', async () => {
const entry = await service.error('admin', 'admin-123', 'Failed to revoke user');
createdIds.push(entry.id);
expect(entry.severity).toBe('error');
});
});
describe('search()', () => {
beforeAll(async () => {
// Create test data for search
const testLogs = [
{ category: 'auth', severity: 'info', action: 'Login successful' },
{ category: 'auth', severity: 'warning', action: 'Login failed' },
{ category: 'vehicle', severity: 'info', action: 'Vehicle created' },
{ category: 'vehicle', severity: 'info', action: 'Vehicle updated' },
{ category: 'admin', severity: 'error', action: 'Admin action failed' },
];
for (const log of testLogs) {
const entry = await service.log(
log.category as any,
log.severity as any,
'test-user',
log.action
);
createdIds.push(entry.id);
}
});
it('should return paginated results', async () => {
const result = await service.search({}, { limit: 10, offset: 0 });
expect(result.logs).toBeInstanceOf(Array);
expect(result.total).toBeGreaterThan(0);
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
});
it('should filter by category', async () => {
const result = await service.search(
{ category: 'auth' },
{ limit: 100, offset: 0 }
);
expect(result.logs.length).toBeGreaterThan(0);
expect(result.logs.every((log) => log.category === 'auth')).toBe(true);
});
it('should filter by severity', async () => {
const result = await service.search(
{ severity: 'error' },
{ limit: 100, offset: 0 }
);
expect(result.logs.every((log) => log.severity === 'error')).toBe(true);
});
it('should search by action text', async () => {
const result = await service.search(
{ search: 'Login' },
{ limit: 100, offset: 0 }
);
expect(result.logs.length).toBeGreaterThan(0);
expect(result.logs.every((log) => log.action.includes('Login'))).toBe(true);
});
});
describe('cleanup()', () => {
it('should delete entries older than specified days', async () => {
// Create an old entry by directly inserting
await pool.query(`
INSERT INTO audit_logs (category, severity, action, created_at)
VALUES ('system', 'info', 'Old test entry', NOW() - INTERVAL '100 days')
`);
const deletedCount = await service.cleanup(90);
expect(deletedCount).toBeGreaterThanOrEqual(1);
});
it('should not delete recent entries', async () => {
const entry = await service.log('system', 'info', null, 'Recent entry');
createdIds.push(entry.id);
await service.cleanup(90);
// Verify entry still exists
const result = await pool.query(
'SELECT id FROM audit_logs WHERE id = $1',
[entry.id]
);
expect(result.rows.length).toBe(1);
});
});
});