231 lines
7.1 KiB
TypeScript
231 lines
7.1 KiB
TypeScript
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
|
import { AppModule } from "@/src/app.module";
|
|
|
|
const request = require("supertest");
|
|
|
|
/**
|
|
* E2E Tests for MFA (Multi-Factor Authentication) Flow
|
|
*
|
|
* Prerequisites:
|
|
* 1. Start test services: docker-compose -f test/docker-compose.yml up -d
|
|
* 2. Run: pnpm test:e2e
|
|
*
|
|
* Tests cover:
|
|
* - MFA status retrieval
|
|
* - TOTP setup flow (secret + QR code generation)
|
|
* - Email MFA setup
|
|
* - MFA challenge during login
|
|
* - Recovery code usage
|
|
* - MFA disable flow (password-verified)
|
|
* - Rate limiting on MFA endpoints
|
|
*/
|
|
describe("MFA Controller (e2e)", () => {
|
|
let app: INestApplication;
|
|
let sessionToken: string;
|
|
|
|
const testUser = {
|
|
email: `mfa-e2e-${Date.now()}@example.com`,
|
|
username: `mfauser${Date.now()}`,
|
|
password: "SecureMfaPass123!",
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
transform: true,
|
|
forbidNonWhitelisted: true,
|
|
}),
|
|
);
|
|
await app.init();
|
|
|
|
// Register and login to get session token
|
|
const registerRes = await request(app.getHttpServer())
|
|
.post("/auth/register")
|
|
.send(testUser);
|
|
|
|
sessionToken = registerRes.body.sessionId;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe("GET /auth/mfa/status", () => {
|
|
it("should return MFA status for authenticated user", () => {
|
|
return request(app.getHttpServer())
|
|
.get("/auth/mfa/status")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.methods).toEqual([]);
|
|
expect(res.body.totpEnabled).toBe(false);
|
|
expect(res.body.emailEnabled).toBe(false);
|
|
expect(res.body.recoveryCodesRemaining).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("should reject unauthenticated requests", () => {
|
|
return request(app.getHttpServer())
|
|
.get("/auth/mfa/status")
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/setup/totp", () => {
|
|
it("should return TOTP setup data with QR code", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/setup/totp")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.secret).toBeDefined();
|
|
expect(res.body.secret.length).toBeGreaterThan(10);
|
|
expect(res.body.otpauthUrl).toBeDefined();
|
|
expect(res.body.otpauthUrl).toContain("otpauth://totp/");
|
|
expect(res.body.qrCodeDataUrl).toBeDefined();
|
|
expect(res.body.qrCodeDataUrl).toContain("data:image/png;base64,");
|
|
});
|
|
});
|
|
|
|
it("should reject unauthenticated TOTP setup", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/setup/totp")
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/setup/email", () => {
|
|
it("should enable email MFA for authenticated user", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/setup/email")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("should show email MFA enabled in status", async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get("/auth/mfa/status")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.emailEnabled).toBe(true);
|
|
expect(res.body.enabled).toBe(true);
|
|
expect(res.body.methods).toContain("email");
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/verify/totp", () => {
|
|
it("should reject invalid TOTP code", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/verify/totp")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.send({ secret: "INVALIDSECRET", code: "000000" })
|
|
.expect(400);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/challenge", () => {
|
|
it("should reject invalid pending session ID", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/challenge")
|
|
.send({
|
|
pendingSessionId: "nonexistent-session-id",
|
|
method: "email",
|
|
code: "123456",
|
|
})
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/send-code", () => {
|
|
it("should reject invalid pending session", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/send-code")
|
|
.send({ pendingSessionId: "nonexistent-session-id" })
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/recovery", () => {
|
|
it("should reject invalid pending session for recovery", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/recovery")
|
|
.send({
|
|
pendingSessionId: "nonexistent-session-id",
|
|
recoveryCode: "ABCD1234",
|
|
})
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/disable", () => {
|
|
it("should reject disable without valid password", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/disable")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.send({ password: "WrongPassword123!", method: "email" })
|
|
.expect(401);
|
|
});
|
|
|
|
it("should disable MFA method with correct password", () => {
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/disable")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.send({ password: testUser.password, method: "email" })
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/mfa/preferred", () => {
|
|
it("should set preferred MFA method", async () => {
|
|
// Re-enable email MFA first
|
|
await request(app.getHttpServer())
|
|
.post("/auth/mfa/setup/email")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.expect(200);
|
|
|
|
return request(app.getHttpServer())
|
|
.post("/auth/mfa/preferred")
|
|
.set("Authorization", `Bearer ${sessionToken}`)
|
|
.send({ method: "email" })
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Full MFA Login Flow (with email MFA enabled)", () => {
|
|
it("should require MFA challenge on login when MFA is enabled", async () => {
|
|
const loginRes = await request(app.getHttpServer())
|
|
.post("/auth/login")
|
|
.send({
|
|
email: testUser.email,
|
|
password: testUser.password,
|
|
})
|
|
.expect(200);
|
|
|
|
// When MFA is enabled, login returns pending session instead of full session
|
|
if (loginRes.body.mfaRequired) {
|
|
expect(loginRes.body.pendingSessionId).toBeDefined();
|
|
expect(loginRes.body.availableMethods).toBeDefined();
|
|
expect(loginRes.body.availableMethods).toContain("email");
|
|
expect(loginRes.body.expiresAt).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
});
|