import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { authenticator } from "otplib"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { totpAuthenticatorCheck } from "@calcom/lib/totp"; import { test } from "./lib/fixtures"; test.describe.configure({ mode: "parallel" }); // TODO: add more backup code tests, e.g. login + disabling 2fa with backup // a test to logout requires both a succesfull login as logout, to prevent // a doubling of tests failing on logout & logout, we can group them. test.describe("2FA Tests", async () => { test.afterAll(async ({ users }) => { await users.deleteAll(); }); test("should allow a user to enable 2FA and login using 2FA", async ({ page, users }) => { // log in trail user const user = await test.step("Enable 2FA", async () => { const user = await users.create(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const userPassword = user.username!; await user.login(); // expects the home page for an authorized user await page.goto("/settings/security/two-factor-auth"); await page.click(`[data-testid=two-factor-switch]`); await page.fill('input[name="password"]', userPassword); await page.press('input[name="password"]', "Enter"); const secret = await page.locator(`[data-testid=two-factor-secret]`).textContent(); expect(secret).toHaveLength(32); await page.click('[data-testid="goto-otp-screen"]'); /** * Try a wrong code and test that wrong code is rejected. */ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: "123456", noRetry: true }); await expect(page.locator('[data-testid="error-submitting-code"]')).toBeVisible(); await fillOtp({ page, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion secret: secret!, }); // FIXME: this passes even when switch is not checked, compare to test // below which checks for data-state="checked" and works as expected await page.waitForSelector(`[data-testid=two-factor-switch]`); await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy(); return user; }); await test.step("Logout", async () => { await page.goto("/auth/logout"); }); await test.step("Login with 2FA enabled", async () => { await user.login(); const userWith2FaSecret = await prisma?.user.findFirst({ where: { id: user.id, }, }); const secret = symmetricDecrypt( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion userWith2FaSecret!.twoFactorSecret!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion process.env.CALENDSO_ENCRYPTION_KEY! ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: secret! }); await Promise.all([ page.press('input[name="2fa6"]', "Enter"), page.waitForResponse("**/api/auth/callback/credentials**"), ]); const shellLocator = page.locator(`[data-testid=dashboard-shell]`); // expects the home page for an authorized user await page.goto("/"); await expect(shellLocator).toBeVisible(); }); }); test("should allow a user to disable 2FA", async ({ page, users }) => { // log in trail user const user = await test.step("Enable 2FA", async () => { const user = await users.create(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const userPassword = user.username!; await user.login(); // expects the home page for an authorized user await page.goto("/settings/security/two-factor-auth"); await page.click(`[data-testid=two-factor-switch][data-state="unchecked"]`); await page.fill('input[name="password"]', userPassword); await page.press('input[name="password"]', "Enter"); const secret = await page.locator(`[data-testid=two-factor-secret]`).textContent(); expect(secret).toHaveLength(32); await page.click('[data-testid="goto-otp-screen"]'); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: secret! }); // backup codes are now showing, so run a few tests // click download button const promise = page.waitForEvent("download"); await page.getByTestId("backup-codes-download").click(); const download = await promise; expect(download.suggestedFilename()).toBe("cal-backup-codes.txt"); // TODO: check file content // click copy button await page.getByTestId("backup-codes-copy").click(); await page.getByTestId("toast-success").waitFor(); // TODO: check clipboard content // close backup code dialog await page.getByTestId("backup-codes-close").click(); await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible(); return user; }); await test.step("Disable 2FA", async () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const userPassword = user.username!; // expects the home page for an authorized user await page.goto("/settings/security/two-factor-auth"); await page.click(`[data-testid=two-factor-switch][data-state="checked"]`); await page.fill('input[name="password"]', userPassword); const userWith2FaSecret = await prisma?.user.findFirst({ where: { id: user.id, }, }); const secret = symmetricDecrypt( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion userWith2FaSecret!.twoFactorSecret!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion process.env.CALENDSO_ENCRYPTION_KEY! ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: secret! }); await page.click('[data-testid="disable-2fa"]'); await expect(page.locator(`[data-testid=two-factor-switch][data-state="unchecked"]`)).toBeVisible(); return user; }); }); }); async function fillOtp({ page, secret, noRetry }: { page: Page; secret: string; noRetry?: boolean }) { let token = authenticator.generate(secret); if (!noRetry && !totpAuthenticatorCheck(token, secret)) { console.log("Token expired, Renerating."); // Maybe token was just about to expire, try again just once more token = authenticator.generate(secret); } await page.fill('input[name="2fa1"]', token[0]); await page.fill('input[name="2fa2"]', token[1]); await page.fill('input[name="2fa3"]', token[2]); await page.fill('input[name="2fa4"]', token[3]); await page.fill('input[name="2fa5"]', token[4]); await page.fill('input[name="2fa6"]', token[5]); }