mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:46:41 +02:00
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:
parent
cc50c0c2ab
commit
c6b1f83f8b
4 changed files with 547 additions and 0 deletions
140
services/mana-core-auth/src/auth/magic-link.spec.ts
Normal file
140
services/mana-core-auth/src/auth/magic-link.spec.ts
Normal 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/*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
156
services/mana-core-auth/src/auth/services/audit-log.spec.ts
Normal file
156
services/mana-core-auth/src/auth/services/audit-log.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue