managarten/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts
Till JS c6b1f83f8b 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>
2026-03-27 11:29:24 +01:00

578 lines
19 KiB
TypeScript

/**
* Passkey & 2FA E2E Tests
*
* Tests the HTTP layer for:
* 1. Passkey registration flow (auth required)
* 2. Passkey authentication flow (public)
* 3. Passkey management (list, rename, delete)
* 4. Auth guard enforcement on passkey endpoints
* 5. 2FA redirect detection during sign-in
* 6. Session-to-token exchange after 2FA verification
*
* WebAuthn crypto is handled by @simplewebauthn/server which is mocked
* at the module level (via jest-e2e.json moduleNameMapper). These tests
* focus on request/response shapes, status codes, and auth guard behavior.
*/
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('Passkey & 2FA (E2E)', () => {
let app: INestApplication;
let accessToken: string;
let refreshToken: string;
let userId: string;
const testEmail = `passkey-e2e-${Date.now()}@example.com`;
const testPassword = 'SecurePassword123!';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
// Register and login a test user for authenticated passkey operations
const registerResponse = await request(app.getHttpServer()).post('/auth/register').send({
email: testEmail,
password: testPassword,
name: 'Passkey E2E User',
});
userId = registerResponse.body.id;
const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({
email: testEmail,
password: testPassword,
});
accessToken = loginResponse.body.accessToken;
refreshToken = loginResponse.body.refreshToken;
});
afterAll(async () => {
await app.close();
});
// =========================================================================
// Passkey Registration Flow
// =========================================================================
describe('Passkey Registration Flow', () => {
it('should generate registration options for authenticated user', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
expect(response.body).toHaveProperty('options');
expect(response.body).toHaveProperty('challengeId');
expect(response.body.options).toHaveProperty('challenge');
expect(typeof response.body.options.challenge).toBe('string');
expect(response.body.options.challenge.length).toBeGreaterThan(0);
expect(typeof response.body.challengeId).toBe('string');
});
it('should include RP info in registration options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
const { options } = response.body;
expect(options).toHaveProperty('rp');
expect(options.rp).toHaveProperty('name');
expect(options.rp).toHaveProperty('id');
});
it('should include user info in registration options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
const { options } = response.body;
expect(options).toHaveProperty('user');
expect(options.user).toHaveProperty('name');
expect(options.user).toHaveProperty('displayName');
});
it('should reject registration verify with invalid challenge', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({
challengeId: 'invalid-challenge-id',
credential: {
id: 'fake-credential-id',
rawId: 'fake-raw-id',
response: {
clientDataJSON: 'fake-client-data',
attestationObject: 'fake-attestation',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toMatch(/invalid|expired/i);
});
it('should reject registration verify with expired challenge', async () => {
// Get valid options but use a bogus challengeId
await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`);
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({
challengeId: 'non-existent-challenge-id',
credential: {
id: 'fake-credential',
rawId: 'fake-raw',
response: {
clientDataJSON: 'fake',
attestationObject: 'fake',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body.message).toMatch(/invalid|expired/i);
});
});
// =========================================================================
// Passkey Authentication Flow (Public Endpoints)
// =========================================================================
describe('Passkey Authentication Flow', () => {
it('should generate authentication options without auth', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body).toHaveProperty('options');
expect(response.body).toHaveProperty('challengeId');
expect(response.body.options).toHaveProperty('challenge');
expect(typeof response.body.options.challenge).toBe('string');
expect(response.body.options.challenge.length).toBeGreaterThan(0);
expect(typeof response.body.challengeId).toBe('string');
});
it('should include rpId in authentication options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body.options).toHaveProperty('rpId');
});
it('should reject authentication verify with invalid challenge', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
challengeId: 'invalid-challenge-id',
credential: {
id: 'fake-credential-id',
rawId: 'fake-raw-id',
response: {
clientDataJSON: 'fake-client-data',
authenticatorData: 'fake-auth-data',
signature: 'fake-signature',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toMatch(/invalid|expired/i);
});
it('should reject authentication verify without challengeId', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
credential: {
id: 'fake-credential',
response: {},
type: 'public-key',
},
})
.expect(400);
});
});
// =========================================================================
// Passkey Management (List, Rename, Delete)
// =========================================================================
describe('Passkey Management', () => {
it('should list passkeys for authenticated user (initially empty)', async () => {
const response = await request(app.getHttpServer())
.get('/auth/passkeys')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// New user should have no passkeys initially
expect(response.body.length).toBe(0);
});
it('should return 404 when deleting non-existent passkey', async () => {
await request(app.getHttpServer())
.delete('/auth/passkeys/non-existent-id')
.set('Authorization', `Bearer ${accessToken}`)
.expect(404);
});
it('should return 404 when renaming non-existent passkey', async () => {
await request(app.getHttpServer())
.patch('/auth/passkeys/non-existent-id')
.set('Authorization', `Bearer ${accessToken}`)
.send({ friendlyName: 'My Key' })
.expect(404);
});
});
// =========================================================================
// Auth Guard Enforcement
// =========================================================================
describe('Auth Guard Enforcement', () => {
describe('Protected endpoints require JWT', () => {
it('POST /auth/passkeys/register/options requires auth', async () => {
await request(app.getHttpServer()).post('/auth/passkeys/register/options').expect(401);
});
it('POST /auth/passkeys/register/verify requires auth', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.send({
challengeId: 'test',
credential: { id: 'test', response: {} },
})
.expect(401);
});
it('GET /auth/passkeys requires auth', async () => {
await request(app.getHttpServer()).get('/auth/passkeys').expect(401);
});
it('DELETE /auth/passkeys/:id requires auth', async () => {
await request(app.getHttpServer()).delete('/auth/passkeys/some-id').expect(401);
});
it('PATCH /auth/passkeys/:id requires auth', async () => {
await request(app.getHttpServer())
.patch('/auth/passkeys/some-id')
.send({ friendlyName: 'test' })
.expect(401);
});
});
describe('Public endpoints do not require JWT', () => {
it('POST /auth/passkeys/authenticate/options is public', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body).toHaveProperty('options');
});
it('POST /auth/passkeys/authenticate/verify is public (fails on invalid data, not auth)', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
challengeId: 'invalid',
credential: { id: 'test', response: {} },
});
// Should get 400 (bad request) not 401 (unauthorized)
expect(response.status).toBe(400);
});
});
describe('Invalid token handling', () => {
it('should reject passkey endpoints with invalid token', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', 'Bearer invalid-jwt-token')
.expect(401);
});
it('should reject passkey endpoints with malformed auth header', async () => {
await request(app.getHttpServer())
.get('/auth/passkeys')
.set('Authorization', 'NotBearer token')
.expect(401);
});
});
});
// =========================================================================
// 2FA Flow via Sign-In
// =========================================================================
describe('2FA Flow', () => {
it('should return standard login response when 2FA is not enabled', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: testEmail,
password: testPassword,
})
.expect(200);
// Normal user without 2FA should get tokens
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
expect(response.body).not.toHaveProperty('twoFactorRedirect');
});
it('session-to-token endpoint should exist', async () => {
// Without a valid session cookie, this should return 401
const response = await request(app.getHttpServer())
.post('/auth/session-to-token')
.expect((res) => {
// Should be 401 (no session cookie) not 404 (endpoint missing)
expect(res.status).not.toBe(404);
expect([200, 401]).toContain(res.status);
});
if (response.status === 401) {
expect(response.body).toHaveProperty('message');
}
});
it('session-to-token should reject request without session cookie', async () => {
await request(app.getHttpServer()).post('/auth/session-to-token').expect(401);
});
});
// =========================================================================
// 2FA Passthrough Endpoints
// =========================================================================
describe('2FA Passthrough Routes', () => {
it('should expose two-factor enable endpoint (requires session)', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/enable')
.send({});
// Should not be 404 - the route exists even if auth fails
expect(response.status).not.toBe(404);
});
it('should expose two-factor verify-totp endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/verify-totp')
.send({ code: '000000' });
// Should not be 404 - the route exists
expect(response.status).not.toBe(404);
});
it('should expose two-factor disable endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/disable')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor get-totp-uri endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/get-totp-uri')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor generate-backup-codes endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/generate-backup-codes')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor verify-backup-code endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/verify-backup-code')
.send({ code: 'fake-backup-code' });
expect(response.status).not.toBe(404);
});
});
// =========================================================================
// Passkey + Login Token Shape Consistency
// =========================================================================
describe('Token Response Shape Consistency', () => {
it('login and passkey-auth-verify should share the same token response shape', async () => {
// Login response shape
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: testEmail,
password: testPassword,
})
.expect(200);
// Verify the login token shape (passkey auth verify returns the same shape)
const tokenKeys = Object.keys(loginResponse.body);
expect(tokenKeys).toContain('user');
expect(tokenKeys).toContain('accessToken');
expect(tokenKeys).toContain('refreshToken');
expect(tokenKeys).toContain('expiresIn');
expect(loginResponse.body.user).toHaveProperty('id');
expect(loginResponse.body.user).toHaveProperty('email');
expect(typeof loginResponse.body.accessToken).toBe('string');
expect(typeof loginResponse.body.refreshToken).toBe('string');
expect(typeof loginResponse.body.expiresIn).toBe('number');
});
});
// =========================================================================
// 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
// =========================================================================
describe('Edge Cases', () => {
it('should handle empty body on register/options gracefully', async () => {
// The endpoint reads user from JWT, so empty body is fine
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.send({});
expect([200, 201]).toContain(response.status);
expect(response.body).toHaveProperty('challengeId');
});
it('should handle missing credential field on register/verify', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({ challengeId: 'some-challenge' });
expect([400, 500]).toContain(response.status);
});
it('should handle missing body on authenticate/verify', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({});
expect([400, 500]).toContain(response.status);
});
it('should not allow cross-user passkey deletion', async () => {
// Create a second user
const otherEmail = `passkey-other-${Date.now()}@example.com`;
await request(app.getHttpServer()).post('/auth/register').send({
email: otherEmail,
password: testPassword,
name: 'Other User',
});
const otherLogin = await request(app.getHttpServer()).post('/auth/login').send({
email: otherEmail,
password: testPassword,
});
const otherToken = otherLogin.body.accessToken;
// Try to delete a non-existent passkey with other user's token
// This should return 404 (not found for this user) not 204
await request(app.getHttpServer())
.delete('/auth/passkeys/some-passkey-id')
.set('Authorization', `Bearer ${otherToken}`)
.expect(404);
});
it('should generate unique challenge IDs across requests', async () => {
const [res1, res2] = await Promise.all([
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
]);
expect(res1.body.challengeId).not.toBe(res2.body.challengeId);
expect(res1.body.options.challenge).not.toBe(res2.body.options.challenge);
});
});
});