mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 09:41:24 +02:00
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>
578 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|