| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- import { type Browser, type BrowserContext, expect, type Page, test } from '@playwright/test'
- import { accountSecurityEntries, storedTwoFactorSecret } from '../../support/db'
- import { fillSettled } from '../../support/hydration'
- import { currentTotpCode } from '../../support/totp'
- /**
- * What a signed-in owner can do to their own account: change the password,
- * and put an authenticator in front of the sign-in.
- *
- * Both flows change how the owner signs in, so each is put back the way it
- * was before the file ends, and the steps run in order.
- */
- test.describe.configure({ mode: 'serial' })
- const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
- const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
- const changed = `E2e-changed-${Date.now()}`
- /** A fresh, signed-out browser context: the way a new sign-in would happen. */
- async function signedOut(browser: Browser): Promise<Page> {
- const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
- return context.newPage()
- }
- async function signIn(page: Page, secret: string) {
- await page.goto('/auth/sign-in')
- await page.locator('#email').fill(email)
- await page.locator('#password').fill(secret)
- await page.getByRole('button', { name: 'Sign In', exact: true }).click()
- }
- async function changePassword(page: Page, from: string, to: string) {
- await page.goto('/settings/account')
- await fillSettled(page.locator('#currentPassword'), from)
- await fillSettled(page.locator('#newPassword'), to)
- await fillSettled(page.locator('#confirmPassword'), to)
- await page.getByRole('button', { name: 'Change Password', exact: true }).click()
- await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
- }
- test.describe('password', () => {
- // One context for both steps, and its session saved afterwards: changing
- // the password ends every session on the account, the caller's included,
- // and hands this context a new one. A fresh context from the saved state
- // would come back with the token the change deleted.
- let owner: BrowserContext
- let page: Page
- test.beforeAll(async ({ browser }) => {
- owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- page = await owner.newPage()
- })
- test.afterAll(async () => {
- await owner.storageState({ path: 'e2e/.auth/owner.json' })
- await owner.close()
- })
- test('is changed from account settings and works at the door', async ({ browser }) => {
- await changePassword(page, password, changed)
- const fresh = await signedOut(browser)
- await signIn(fresh, changed)
- await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
- await fresh.context().close()
- })
- test('is put back for the rest of the suite', async () => {
- await changePassword(page, changed, password)
- })
- })
- test.describe('two-factor authentication', () => {
- // One browser context for the three steps. Enabling 2FA makes better-auth
- // rotate the session, and a fresh context per test would come back with
- // the token from setup, which the rotation deleted: the pages would still
- // render off the cookie cache, but anything sensitive would be refused.
- let owner: BrowserContext
- let page: Page
- // Each change below leaves a line in the audit log, counted from here. A
- // second back: the row is stamped by the database's clock, not this one.
- let since: Date
- let backupCodes: string[] = []
- test.beforeAll(async ({ browser }) => {
- since = new Date(Date.now() - 1_000)
- owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
- page = await owner.newPage()
- })
- test.afterAll(async () => {
- // The rest of the suite signs in with the saved state; hand it the
- // session this context ended up with, not the one 2FA retired.
- await owner.storageState({ path: 'e2e/.auth/owner.json' })
- await owner.close()
- })
- test('an authenticator app is enrolled with a code it generates', async () => {
- await page.goto('/settings/account')
- // Ids that start with a digit are not valid CSS selectors, hence the attribute form.
- const dialog = page.getByRole('dialog')
- await expect(async () => {
- await page.getByRole('button', { name: 'Enable 2FA', exact: true }).click()
- await expect(dialog.locator('[id="2fa-enable-password"]')).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await dialog.locator('[id="2fa-enable-password"]').fill(password)
- await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
- // The QR code step. The secret it encodes is in the database by now,
- // which is how the test plays the part of the phone.
- await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
- const stored = await storedTwoFactorSecret(email)
- expect(stored, 'better-auth stored a secret when 2FA was enabled').not.toBeNull()
- await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
- await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
- // The codes the owner is asked to keep, for the backup-code sign-in below.
- // Read once they are on screen: the step appears when the code is accepted.
- const saved = dialog.getByRole('button', { name: /saved my backup codes/i })
- await expect(saved).toBeVisible()
- backupCodes = (await dialog.innerText()).match(/\b[A-Za-z0-9]{5}-[A-Za-z0-9]{5}\b/g) ?? []
- expect(backupCodes.length, 'the dialog shows backup codes to keep').toBeGreaterThan(0)
- await saved.click()
- await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
- await expect
- .poll(() => accountSecurityEntries(email, since))
- .toEqual(['Turned on two-factor sign-in'])
- })
- test('signing in now asks for the code before opening anything', async ({ browser }) => {
- const fresh = await signedOut(browser)
- await signIn(fresh, password)
- await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
- // Nothing behind the door without the code.
- await fresh.goto('/customers')
- await expect(fresh).toHaveURL(/\/auth\//)
- await fresh.goto('/auth/verify-2fa')
- const stored = await storedTwoFactorSecret(email)
- await fresh.locator('#code').fill(await currentTotpCode(stored as string))
- await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
- await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
- await fresh.context().close()
- })
- test('a backup code opens the account in place of the app, and is logged', async ({
- browser,
- }) => {
- const fresh = await signedOut(browser)
- await signIn(fresh, password)
- await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
- await fresh.getByRole('button', { name: 'Use a backup code', exact: true }).click()
- // A wrong code opens nothing and is not a sign-in to log.
- await fresh.locator('#code').fill('wrong-codes')
- await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
- await expect(fresh.getByRole('button', { name: 'Verify', exact: true })).toBeEnabled()
- await expect(fresh).toHaveURL(/\/auth\/verify-2fa/)
- await fresh.locator('#code').fill(backupCodes[0])
- await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
- await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
- await expect
- .poll(() => accountSecurityEntries(email, since))
- .toEqual(['Turned on two-factor sign-in', 'Signed in with a backup code'])
- await fresh.context().close()
- })
- test('is switched off again with the password', async () => {
- await page.goto('/settings/account')
- await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
- await page.getByRole('button', { name: 'Disable 2FA', exact: true }).click()
- // Off in the page, and gone from the database.
- await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeVisible({
- timeout: 15_000,
- })
- expect(await storedTwoFactorSecret(email)).toBeNull()
- await expect
- .poll(() => accountSecurityEntries(email, since))
- .toEqual([
- 'Turned on two-factor sign-in',
- 'Signed in with a backup code',
- 'Turned off two-factor sign-in',
- ])
- })
- })
|