feat: add userName tracking to audit events

- Add userName column to AuditEvent schema with index and migration
- Add GET /api/v1/auth/me endpoint returning current user identity
- AuditCollector auto-fills userName from session→user map, resolved
  lazily via /auth/me on first session creation
- Support userName and date range (from/to) filtering on audit events
  and sessions endpoints
- Audit console sidebar groups sessions by project → user
- Add date filter presets (d key: all/today/1h/24h/7d) to console
- Add scrolling and page up/down to sidebar navigation
- Tests: auth-me (4), audit-username collector (4), route filters (2),
  smoke tests (2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 00:18:58 +00:00
parent 75c44e4ba1
commit 86c5a61eaa
17 changed files with 689 additions and 79 deletions

View File

@@ -179,11 +179,12 @@ describe('audit event routes', () => {
});
describe('GET /api/v1/audit/sessions', () => {
it('returns session summaries', async () => {
it('returns session summaries with userName', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([
{
sessionId: 'sess-1',
projectName: 'ha-project',
userName: 'michal',
firstSeen: new Date('2026-03-01T12:00:00Z'),
lastSeen: new Date('2026-03-01T12:05:00Z'),
eventCount: 5,
@@ -202,6 +203,7 @@ describe('audit event routes', () => {
expect(body.sessions).toHaveLength(1);
expect(body.sessions[0].sessionId).toBe('sess-1');
expect(body.sessions[0].eventCount).toBe(5);
expect(body.sessions[0].userName).toBe('michal');
expect(body.total).toBe(1);
});
@@ -218,6 +220,33 @@ describe('audit event routes', () => {
expect(call.projectName).toBe('ha-project');
});
it('filters by userName', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(0);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?userName=michal',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { userName?: string };
expect(call.userName).toBe('michal');
});
it('filters by date range (from/to)', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(0);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?from=2026-03-01&to=2026-03-02',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { from?: Date; to?: Date };
expect(call.from).toEqual(new Date('2026-03-01'));
expect(call.to).toEqual(new Date('2026-03-02'));
});
it('supports pagination', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(10);

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerAuthRoutes } from '../src/routes/auth.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { SafeUser } from '../src/repositories/user.repository.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function makeSafeUser(overrides?: Partial<SafeUser>): SafeUser {
return {
id: 'user-1',
email: 'michal@example.com',
name: 'Michal',
role: 'user',
provider: 'local',
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockDeps() {
return {
authService: {
login: vi.fn(async () => ({})),
logout: vi.fn(async () => {}),
findSession: vi.fn(async () => null),
impersonate: vi.fn(async () => ({})),
},
userService: {
count: vi.fn(async () => 1),
create: vi.fn(async () => makeSafeUser()),
list: vi.fn(async () => []),
getById: vi.fn(async () => makeSafeUser()),
getByEmail: vi.fn(async () => makeSafeUser()),
delete: vi.fn(async () => {}),
},
groupService: {
create: vi.fn(async () => ({})),
list: vi.fn(async () => []),
getById: vi.fn(async () => null),
getByName: vi.fn(async () => null),
update: vi.fn(async () => null),
delete: vi.fn(async () => {}),
},
rbacDefinitionService: {
create: vi.fn(async () => ({})),
list: vi.fn(async () => []),
getById: vi.fn(async () => null),
getByName: vi.fn(async () => null),
update: vi.fn(async () => null),
delete: vi.fn(async () => {}),
},
rbacService: {
canAccess: vi.fn(async () => false),
canRunOperation: vi.fn(async () => false),
getPermissions: vi.fn(async () => []),
},
};
}
describe('GET /api/v1/auth/me', () => {
it('returns user identity for authenticated request', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
});
deps.userService.getById.mockResolvedValue(makeSafeUser({ id: 'user-1', name: 'Michal', email: 'michal@example.com' }));
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
const body = res.json<{ id: string; email: string; name: string | null }>();
expect(body.id).toBe('user-1');
expect(body.email).toBe('michal@example.com');
expect(body.name).toBe('Michal');
});
it('returns null name when user has no name', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
});
deps.userService.getById.mockResolvedValue(makeSafeUser({ name: null }));
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json<{ name: string | null }>().name).toBeNull();
});
it('returns 401 for unauthenticated request', async () => {
const deps = createMockDeps();
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
});
expect(res.statusCode).toBe(401);
});
it('returns 401 for invalid token', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue(null);
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer bad-token' },
});
expect(res.statusCode).toBe(401);
});
});