account.spec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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. // One context for both steps, and its session saved afterwards: changing
  37. // the password ends every session on the account, the caller's included,
  38. // and hands this context a new one. A fresh context from the saved state
  39. // would come back with the token the change deleted.
  40. let owner: BrowserContext
  41. let page: Page
  42. test.beforeAll(async ({ browser }) => {
  43. owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  44. page = await owner.newPage()
  45. })
  46. test.afterAll(async () => {
  47. await owner.storageState({ path: 'e2e/.auth/owner.json' })
  48. await owner.close()
  49. })
  50. test('is changed from account settings and works at the door', async ({ browser }) => {
  51. await changePassword(page, password, changed)
  52. const fresh = await signedOut(browser)
  53. await signIn(fresh, changed)
  54. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  55. await fresh.context().close()
  56. })
  57. test('is put back for the rest of the suite', async () => {
  58. await changePassword(page, changed, password)
  59. })
  60. })
  61. test.describe('two-factor authentication', () => {
  62. // One browser context for the three steps. Enabling 2FA makes better-auth
  63. // rotate the session, and a fresh context per test would come back with
  64. // the token from setup, which the rotation deleted: the pages would still
  65. // render off the cookie cache, but anything sensitive would be refused.
  66. let owner: BrowserContext
  67. let page: Page
  68. test.beforeAll(async ({ browser }) => {
  69. owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  70. page = await owner.newPage()
  71. })
  72. test.afterAll(async () => {
  73. // The rest of the suite signs in with the saved state; hand it the
  74. // session this context ended up with, not the one 2FA retired.
  75. await owner.storageState({ path: 'e2e/.auth/owner.json' })
  76. await owner.close()
  77. })
  78. test('an authenticator app is enrolled with a code it generates', async () => {
  79. await page.goto('/settings/account')
  80. // Ids that start with a digit are not valid CSS selectors, hence the attribute form.
  81. const dialog = page.getByRole('dialog')
  82. await expect(async () => {
  83. await page.getByRole('button', { name: 'Enable 2FA', exact: true }).click()
  84. await expect(dialog.locator('[id="2fa-enable-password"]')).toBeVisible({ timeout: 2_000 })
  85. }).toPass({ timeout: 30_000 })
  86. await dialog.locator('[id="2fa-enable-password"]').fill(password)
  87. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  88. // The QR code step. The secret it encodes is in the database by now,
  89. // which is how the test plays the part of the phone.
  90. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  91. const stored = await storedTwoFactorSecret(email)
  92. expect(stored, 'better-auth stored a secret when 2FA was enabled').not.toBeNull()
  93. await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
  94. await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
  95. await dialog.getByRole('button', { name: /saved my backup codes/i }).click()
  96. await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
  97. })
  98. test('signing in now asks for the code before opening anything', async ({ browser }) => {
  99. const fresh = await signedOut(browser)
  100. await signIn(fresh, password)
  101. await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
  102. // Nothing behind the door without the code.
  103. await fresh.goto('/customers')
  104. await expect(fresh).toHaveURL(/\/auth\//)
  105. await fresh.goto('/auth/verify-2fa')
  106. const stored = await storedTwoFactorSecret(email)
  107. await fresh.locator('#code').fill(await currentTotpCode(stored as string))
  108. await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
  109. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  110. await fresh.context().close()
  111. })
  112. test('is switched off again with the password', async () => {
  113. await page.goto('/settings/account')
  114. await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
  115. await page.getByRole('button', { name: 'Disable 2FA', exact: true }).click()
  116. // Off in the page, and gone from the database.
  117. await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeVisible({
  118. timeout: 15_000,
  119. })
  120. expect(await storedTwoFactorSecret(email)).toBeNull()
  121. })
  122. })