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:
@@ -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);
|
||||
|
||||
152
src/mcpd/tests/auth-me.test.ts
Normal file
152
src/mcpd/tests/auth-me.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user