Testing 2FA Flows
2FA adds complexity to testing
The login flow splits into two steps when 2FA is enabled. Tests must verify both steps and the security properties of the intermediate state.
Testing the two-step flow
import { TOTP } from "otpauth";
// Helper: generate a valid TOTP code for testing
function generateTestCode(secret: string): string {
const totp = new TOTP({ secret, algorithm: "SHA1", digits: 6, period: 30 });
return totp.generate();
}
describe("2FA login flow", () => {
// Assume alice has 2FA enabled with a known secret
const ALICE_TOTP_SECRET = "TESTSECRETFORTESTING"; // Set in test seed
it("password step returns requiresTwoFactor", async () => {
const res = await login("[email protected]", "password123");
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.requiresTwoFactor, true);
});
it("pending session cannot access protected routes", async () => {
const res = await login("[email protected]", "password123");
const cookie = getCookie(res);
const protectedRes = await authenticatedRequest("/me", cookie);
assert.strictEqual(
protectedRes.status,
401,
"Pending session must not access protected routes",
);
});
it("valid TOTP code completes login", async () => {
const loginRes = await login("[email protected]", "password123");
const cookie = getCookie(loginRes);
const code = generateTestCode(ALICE_TOTP_SECRET);
const res = await authenticatedRequest("/login/2fa", cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
assert.strictEqual(res.status, 200);
// Session should now be active
const meRes = await authenticatedRequest("/me", cookie);
assert.strictEqual(meRes.status, 200);
});
it("invalid TOTP code returns 401", async () => {
const loginRes = await login("[email protected]", "password123");
const cookie = getCookie(loginRes);
const res = await authenticatedRequest("/login/2fa", cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code: "000000" }),
});
assert.strictEqual(res.status, 401);
// Session should still be pending
const meRes = await authenticatedRequest("/me", cookie);
assert.strictEqual(meRes.status, 401);
});
it("2FA endpoint without pending session returns 401", async () => {
const res = await request("/login/2fa", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code: "123456" }),
});
assert.strictEqual(res.status, 401);
});
}); The key test is “pending session cannot access protected routes.” This verifies that the empty-userId design from the 2FA course actually blocks access.
Testing recovery codes
describe("recovery codes", () => {
it("valid recovery code completes login", async () => {
const loginRes = await login("[email protected]", "password123");
const cookie = getCookie(loginRes);
// Use a known recovery code from test seed
const res = await authenticatedRequest("/login/2fa", cookie, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ recoveryCode: "test-code-1" }),
});
assert.strictEqual(res.status, 200);
});
it("recovery code works only once", async () => {
// First use
const loginRes1 = await login("[email protected]", "password123");
const cookie1 = getCookie(loginRes1);
await authenticatedRequest("/login/2fa", cookie1, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ recoveryCode: "test-code-2" }),
});
// Second use (same code)
const loginRes2 = await login("[email protected]", "password123");
const cookie2 = getCookie(loginRes2);
const res = await authenticatedRequest("/login/2fa", cookie2, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ recoveryCode: "test-code-2" }),
});
assert.strictEqual(res.status, 401, "Recovery code must be single-use");
});
}); The single-use test is critical. Without it, a captured recovery code could be used forever.
Exercises
Exercise 1: Write the 2FA flow tests. You will need to seed a user with a known TOTP secret in your test setup.
Exercise 2: Write a test that verifies the TOTP code is time-sensitive: generate a code, advance the clock (or wait), and verify it expires.
Exercise 3: Write a test for the 2FA setup flow: call /me/2fa/setup, extract the secret, generate a code, call /me/2fa/verify. Verify the user now requires 2FA on login.
Why is the 'pending session cannot access protected routes' test the most important 2FA test?