account.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { type Browser, type BrowserContext, expect, type Page, test } from '@playwright/test'
  2. import { storedTwoFactorSecret } from '../../support/db'
  3. import { fillSettled } from '../../support/hydration'
  4. import { currentTotpCode } from '../../support/totp'
  5. /**
  6. * What a signed-in owner can do to their own account: change the password,
  7. * and put an authenticator in front of the sign-in.
  8. *
  9. * Both flows change how the owner signs in, so each is put back the way it
  10. * was before the file ends, and the steps run in order.
  11. */
  12. test.describe.configure({ mode: 'serial' })
  13. const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
  14. const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
  15. const changed = `E2e-changed-${Date.now()}`
  16. /** A fresh, signed-out browser context: the way a new sign-in would happen. */
  17. async function signedOut(browser: Browser): Promise<Page> {
  18. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  19. return context.newPage()
  20. }
  21. async function signIn(page: Page, secret: string) {
  22. await page.goto('/auth/sign-in')
  23. await page.locator('#email').fill(email)
  24. await page.locator('#password').fill(secret)
  25. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  26. }
  27. async function changePassword(page: Page, from: string, to: string) {
  28. await page.goto('/settings/account')
  29. await fillSettled(page.locator('#currentPassword'), from)
  30. await fillSettled(page.locator('#newPassword'), to)
  31. await fillSettled(page.locator('#confirmPassword'), to)
  32. await page.getByRole('button', { name: 'Change Password', exact: true }).click()
  33. await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
  34. }
  35. test.describe('password', () => {
  36. test('is changed from account settings and works at the door', async ({ page, browser }) => {
  37. await changePassword(page, password, changed)
  38. const fresh = await signedOut(browser)
  39. await signIn(fresh, changed)
  40. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  41. await fresh.context().close()
  42. })
  43. test('is put back for the rest of the suite', async ({ page }) => {
  44. await changePassword(page, changed, password)
  45. })
  46. })
  47. test.describe('two-factor authentication', () => {
  48. // One browser context for the three steps. Enabling 2FA makes better-auth
  49. // rotate the session, and a fresh context per test would come back with
  50. // the token from setup, which the rotation deleted: the pages would still
  51. // render off the cookie cache, but anything sensitive would be refused.
  52. let owner: BrowserContext
  53. let page: Page
  54. test.beforeAll(async ({ browser }) => {
  55. owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  56. page = await owner.newPage()
  57. })
  58. test.afterAll(async () => {
  59. // The rest of the suite signs in with the saved state; hand it the
  60. // session this context ended up with, not the one 2FA retired.
  61. await owner.storageState({ path: 'e2e/.auth/owner.json' })
  62. await owner.close()
  63. })
  64. test('an authenticator app is enrolled with a code it generates', async () => {
  65. await page.goto('/settings/account')
  66. // Ids that start with a digit are not valid CSS selectors, hence the attribute form.
  67. const dialog = page.getByRole('dialog')
  68. await expect(async () => {
  69. await page.getByRole('button', { name: 'Enable 2FA', exact: true }).click()
  70. await expect(dialog.locator('[id="2fa-enable-password"]')).toBeVisible({ timeout: 2_000 })
  71. }).toPass({ timeout: 30_000 })
  72. await dialog.locator('[id="2fa-enable-password"]').fill(password)
  73. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  74. // The QR code step. The secret it encodes is in the database by now,
  75. // which is how the test plays the part of the phone.
  76. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  77. const stored = await storedTwoFactorSecret(email)
  78. expect(stored, 'better-auth stored a secret when 2FA was enabled').not.toBeNull()
  79. await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
  80. await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
  81. await dialog.getByRole('button', { name: /saved my backup codes/i }).click()
  82. await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
  83. })
  84. test('signing in now asks for the code before opening anything', async ({ browser }) => {
  85. const fresh = await signedOut(browser)
  86. await signIn(fresh, password)
  87. await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
  88. // Nothing behind the door without the code.
  89. await fresh.goto('/customers')
  90. await expect(fresh).toHaveURL(/\/auth\//)
  91. await fresh.goto('/auth/verify-2fa')
  92. const stored = await storedTwoFactorSecret(email)
  93. await fresh.locator('#code').fill(await currentTotpCode(stored as string))
  94. await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
  95. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  96. await fresh.context().close()
  97. })
  98. test('is switched off again with the password', async () => {
  99. await page.goto('/settings/account')
  100. await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
  101. await page.getByRole('button', { name: 'Disable 2FA', exact: true }).click()
  102. // Off in the page, and gone from the database.
  103. await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeVisible({
  104. timeout: 15_000,
  105. })
  106. expect(await storedTwoFactorSecret(email)).toBeNull()
  107. })
  108. })