test(auth): add tests for audit log, magic links, and security events

Unit tests (12 new):
- Security events controller: endpoint returns events, guard config
- Audit log service: DB query, ordering, limit, empty results
- Magic link passthrough: route exists, delegates to Better Auth

E2E tests (5 new):
- Magic link routes are routable (send + verify)
- Security events endpoint auth + response shape

Total auth tests: 47 unit + ~35 E2E = 82+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 11:29:24 +01:00
parent cc50c0c2ab
commit c6b1f83f8b
4 changed files with 547 additions and 0 deletions

View file

@ -0,0 +1,140 @@
/**
* Magic Link Passthrough Unit Tests
*
* Tests that the BetterAuthPassthroughController has the magic link
* handler method and that it delegates to forwardToBetterAuth.
*/
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
import { BetterAuthService } from './services/better-auth.service';
import { LoggerService } from '../common/logger';
describe('BetterAuthPassthroughController - Magic Link', () => {
let controller: BetterAuthPassthroughController;
let betterAuthService: jest.Mocked<BetterAuthService>;
const mockBetterAuthService = {
getHandler: jest.fn(),
verifyEmail: jest.fn(),
getSourceAppUrl: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
BASE_URL: 'http://localhost:3001',
};
return config[key] || '';
}),
};
const mockLoggerService = {
setContext: jest.fn().mockReturnThis(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BetterAuthPassthroughController],
providers: [
{ provide: BetterAuthService, useValue: mockBetterAuthService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: LoggerService, useValue: mockLoggerService },
],
}).compile();
controller = module.get<BetterAuthPassthroughController>(BetterAuthPassthroughController);
betterAuthService = module.get(BetterAuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// Magic Link Handler Existence
// ============================================================================
describe('handleMagicLink', () => {
it('should have handleMagicLink method defined', () => {
expect(controller.handleMagicLink).toBeDefined();
expect(typeof controller.handleMagicLink).toBe('function');
});
it('should call forwardToBetterAuth and delegate to Better Auth handler', async () => {
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
const mockHandler = jest.fn().mockResolvedValue(mockResponse);
betterAuthService.getHandler.mockReturnValue(mockHandler);
const mockReq = {
method: 'POST',
originalUrl: '/api/auth/magic-link/send-magic-link',
headers: { 'content-type': 'application/json' },
body: { email: 'test@example.com' },
} as any;
const mockRes = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
append: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as any;
await controller.handleMagicLink(mockReq, mockRes);
expect(betterAuthService.getHandler).toHaveBeenCalled();
expect(mockHandler).toHaveBeenCalled();
});
it('should return 500 on internal error', async () => {
betterAuthService.getHandler.mockImplementation(() => {
throw new Error('Handler unavailable');
});
const mockReq = {
method: 'POST',
originalUrl: '/api/auth/magic-link/send-magic-link',
headers: {},
body: {},
} as any;
const mockRes = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
append: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as any;
await controller.handleMagicLink(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Magic link request failed' });
});
});
// ============================================================================
// Route Metadata
// ============================================================================
describe('Route metadata', () => {
it('should have @All decorator on handleMagicLink for magic-link/* routes', () => {
const routePath = Reflect.getMetadata(
'path',
BetterAuthPassthroughController.prototype.handleMagicLink
);
expect(routePath).toBe('magic-link/*');
});
});
});

View file

@ -0,0 +1,196 @@
/**
* AuthController Security Events Unit Tests
*
* Tests the security events / audit log endpoint on the AuthController:
*
* - GET /auth/security-events - List user's security events
*/
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { AuthController } from './auth.controller';
import { BetterAuthService } from './services/better-auth.service';
import { PasskeyService } from './services/passkey.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security';
describe('AuthController - Security Events', () => {
let controller: AuthController;
let betterAuthService: jest.Mocked<BetterAuthService>;
const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };
const mockReq = {
headers: { 'user-agent': 'test-agent' },
ip: '127.0.0.1',
} as any;
beforeEach(async () => {
const mockPasskeyService = {
generateRegistrationOptions: jest.fn(),
verifyRegistration: jest.fn(),
generateAuthenticationOptions: jest.fn(),
verifyAuthentication: jest.fn(),
listPasskeys: jest.fn(),
deletePasskey: jest.fn(),
renamePasskey: jest.fn(),
};
const mockBetterAuthService = {
registerB2C: jest.fn(),
registerB2B: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
getSession: jest.fn(),
listOrganizations: jest.fn(),
getOrganization: jest.fn(),
getOrganizationMembers: jest.fn(),
inviteEmployee: jest.fn(),
acceptInvitation: jest.fn(),
removeMember: jest.fn(),
setActiveOrganization: jest.fn(),
refreshToken: jest.fn(),
validateToken: jest.fn(),
createSessionAndTokens: jest.fn(),
requestPasswordReset: jest.fn(),
resetPassword: jest.fn(),
resendVerificationEmail: jest.fn(),
getProfile: jest.fn(),
updateProfile: jest.fn(),
changePassword: jest.fn(),
deleteAccount: jest.fn(),
sessionToToken: jest.fn(),
getJwks: jest.fn(),
updateOrganization: jest.fn(),
deleteOrganization: jest.fn(),
updateMemberRole: jest.fn(),
listOrganizationInvitations: jest.fn(),
listUserInvitations: jest.fn(),
cancelInvitation: jest.fn(),
rejectInvitation: jest.fn(),
getSecurityEvents: jest.fn(),
};
const mockSecurityEventsService = {
logEvent: jest.fn().mockResolvedValue(undefined),
logEventWithRequest: jest.fn().mockResolvedValue(undefined),
extractRequestInfo: jest.fn().mockReturnValue({
ipAddress: '127.0.0.1',
userAgent: 'test-agent',
}),
};
const mockAccountLockoutService = {
checkLockout: jest.fn().mockResolvedValue({ locked: false }),
recordAttempt: jest.fn().mockResolvedValue(undefined),
clearAttempts: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
controllers: [AuthController],
providers: [
{ provide: BetterAuthService, useValue: mockBetterAuthService },
{ provide: PasskeyService, useValue: mockPasskeyService },
{ provide: SecurityEventsService, useValue: mockSecurityEventsService },
{ provide: AccountLockoutService, useValue: mockAccountLockoutService },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.overrideGuard(ThrottlerGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<AuthController>(AuthController);
betterAuthService = module.get(BetterAuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// GET /auth/security-events
// ============================================================================
describe('GET /auth/security-events', () => {
it("should return user's events from BetterAuthService", async () => {
const mockEvents = [
{
id: 'evt-1',
eventType: 'login_success',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
metadata: { email: 'test@example.com' },
createdAt: new Date('2026-03-27T10:00:00Z'),
},
{
id: 'evt-2',
eventType: 'password_changed',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
metadata: {},
createdAt: new Date('2026-03-26T09:00:00Z'),
},
];
betterAuthService.getSecurityEvents.mockResolvedValue(mockEvents);
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
expect(result).toEqual(mockEvents);
expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123');
});
it('should return empty array when no events exist', async () => {
betterAuthService.getSecurityEvents.mockResolvedValue([]);
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
expect(result).toEqual([]);
expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123');
});
it('should return events in descending order by createdAt', async () => {
const newerEvent = {
id: 'evt-1',
eventType: 'login_success',
ipAddress: '127.0.0.1',
userAgent: 'test',
metadata: {},
createdAt: new Date('2026-03-27T12:00:00Z'),
};
const olderEvent = {
id: 'evt-2',
eventType: 'logout',
ipAddress: '127.0.0.1',
userAgent: 'test',
metadata: {},
createdAt: new Date('2026-03-26T08:00:00Z'),
};
// BetterAuthService already orders them desc by createdAt
betterAuthService.getSecurityEvents.mockResolvedValue([newerEvent, olderEvent]);
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
expect(result).toHaveLength(2);
expect(new Date(result[0].createdAt).getTime()).toBeGreaterThan(
new Date(result[1].createdAt).getTime()
);
});
});
// ============================================================================
// Guard Configuration
// ============================================================================
describe('Security Events Guard Configuration', () => {
it('should have JwtAuthGuard on getSecurityEvents', () => {
const guards = Reflect.getMetadata('__guards__', AuthController.prototype.getSecurityEvents);
expect(guards).toBeDefined();
expect(guards).toContain(JwtAuthGuard);
});
});
});

View file

@ -0,0 +1,156 @@
/**
* BetterAuthService.getSecurityEvents Unit Tests
*
* Tests the audit log / security events query method.
* Uses the thenable DB mock pattern from passkey.service.spec.ts.
*
* Since BetterAuthService has complex constructor dependencies (Better Auth,
* OIDC provider), we mock the better-auth.config module and the DB connection.
*/
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getDb } from '../../db/connection';
import { LoggerService } from '../../common/logger';
// Mock better-auth config to avoid oidcProvider instantiation
jest.mock('../better-auth.config', () => ({
createBetterAuth: jest.fn(() => ({
api: {},
handler: jest.fn(),
})),
}));
jest.mock('../../db/connection', () => ({
getDb: jest.fn(),
}));
const createMockDb = () => {
let results: any[] = [];
let resultIndex = 0;
const db: any = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),
setResults: (...r: any[]) => {
results = r;
resultIndex = 0;
},
};
return db;
};
// Import after mocks are set up
import { BetterAuthService } from './better-auth.service';
describe('BetterAuthService - getSecurityEvents', () => {
let service: BetterAuthService;
let mockDb: ReturnType<typeof createMockDb>;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
const config: Record<string, string> = {
'database.url': 'postgresql://test:test@localhost:5432/test',
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
JWT_ISSUER: 'manacore',
JWT_AUDIENCE: 'manacore',
BASE_URL: 'http://localhost:3001',
};
return config[key] || defaultValue || '';
}),
};
const mockLoggerService = {
setContext: jest.fn().mockReturnThis(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
};
beforeEach(async () => {
mockDb = createMockDb();
(getDb as jest.Mock).mockReturnValue(mockDb);
const module: TestingModule = await Test.createTestingModule({
providers: [
BetterAuthService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: LoggerService, useValue: mockLoggerService },
],
}).compile();
service = module.get<BetterAuthService>(BetterAuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return events for a given userId ordered by createdAt desc', async () => {
const mockEvents = [
{
id: 'evt-1',
eventType: 'login_success',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
metadata: { email: 'test@example.com' },
createdAt: new Date('2026-03-27T10:00:00Z'),
},
{
id: 'evt-2',
eventType: 'logout',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
metadata: {},
createdAt: new Date('2026-03-26T09:00:00Z'),
},
];
mockDb.setResults(mockEvents);
const result = await service.getSecurityEvents('user-123');
expect(result).toEqual(mockEvents);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
expect(mockDb.orderBy).toHaveBeenCalled();
expect(mockDb.limit).toHaveBeenCalled();
});
it('should limit results to default of 50', async () => {
mockDb.setResults([]);
await service.getSecurityEvents('user-123');
expect(mockDb.limit).toHaveBeenCalledWith(50);
});
it('should respect custom limit parameter', async () => {
mockDb.setResults([]);
await service.getSecurityEvents('user-123', 10);
expect(mockDb.limit).toHaveBeenCalledWith(10);
});
it('should return empty array when no events exist', async () => {
mockDb.setResults([]);
const result = await service.getSecurityEvents('user-123');
expect(result).toEqual([]);
});
});

View file

@ -453,6 +453,61 @@ describe('Passkey & 2FA (E2E)', () => {
});
});
// =========================================================================
// Magic Link Flow
// =========================================================================
describe('Magic Link Flow', () => {
it('POST /api/auth/magic-link/send-magic-link should be routable', async () => {
const res = await request(app.getHttpServer())
.post('/api/auth/magic-link/send-magic-link')
.send({ email: 'test@example.com' });
// Should not be 404 (route exists)
expect(res.status).not.toBe(404);
});
it('GET /api/auth/magic-link/verify should be routable', async () => {
const res = await request(app.getHttpServer())
.get('/api/auth/magic-link/verify')
.query({ token: 'invalid-token' });
expect(res.status).not.toBe(404);
});
});
// =========================================================================
// Security Events / Audit Log
// =========================================================================
describe('Security Events / Audit Log', () => {
it('GET /auth/security-events requires authentication', async () => {
const res = await request(app.getHttpServer()).get('/auth/security-events');
expect(res.status).toBe(401);
});
it('GET /auth/security-events returns events for authenticated user', async () => {
const res = await request(app.getHttpServer())
.get('/auth/security-events')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('GET /auth/security-events returns events with expected shape', async () => {
const res = await request(app.getHttpServer())
.get('/auth/security-events')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
// User has logged in at least once, so there should be events
if (res.body.length > 0) {
const event = res.body[0];
expect(event).toHaveProperty('id');
expect(event).toHaveProperty('eventType');
expect(event).toHaveProperty('createdAt');
}
});
});
// =========================================================================
// Edge Cases
// =========================================================================