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>
208 lines
6.1 KiB
TypeScript
208 lines
6.1 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|
|
});
|