account.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { type Browser, type BrowserContext, expect, type Page, test } from '@playwright/test'
  2. import { accountSecurityEntries, 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. // Each change below leaves a line in the audit log, counted from here. A
  69. // second back: the row is stamped by the database's clock, not this one.
  70. let since: Date
  71. let backupCodes: string[] = []
  72. test.beforeAll(async ({ browser }) => {
  73. since = new Date(Date.now() - 1_000)
  74. owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
  75. page = await owner.newPage()
  76. })
  77. test.afterAll(async () => {
  78. // The rest of the suite signs in with the saved state; hand it the
  79. // session this context ended up with, not the one 2FA retired.
  80. await owner.storageState({ path: 'e2e/.auth/owner.json' })
  81. await owner.close()
  82. })
  83. test('an authenticator app is enrolled with a code it generates', async () => {
  84. await page.goto('/settings/account')
  85. // Ids that start with a digit are not valid CSS selectors, hence the attribute form.
  86. const dialog = page.getByRole('dialog')
  87. await expect(async () => {
  88. await page.getByRole('button', { name: 'Enable 2FA', exact: true }).click()
  89. await expect(dialog.locator('[id="2fa-enable-password"]')).toBeVisible({ timeout: 2_000 })
  90. }).toPass({ timeout: 30_000 })
  91. await dialog.locator('[id="2fa-enable-password"]').fill(password)
  92. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  93. // The QR code step. The secret it encodes is in the database by now,
  94. // which is how the test plays the part of the phone.
  95. await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
  96. const stored = await storedTwoFactorSecret(email)
  97. expect(stored, 'better-auth stored a secret when 2FA was enabled').not.toBeNull()
  98. await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
  99. await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
  100. // The codes the owner is asked to keep, for the backup-code sign-in below.
  101. // Read once they are on screen: the step appears when the code is accepted.
  102. const saved = dialog.getByRole('button', { name: /saved my backup codes/i })
  103. await expect(saved).toBeVisible()
  104. backupCodes = (await dialog.innerText()).match(/\b[A-Za-z0-9]{5}-[A-Za-z0-9]{5}\b/g) ?? []
  105. expect(backupCodes.length, 'the dialog shows backup codes to keep').toBeGreaterThan(0)
  106. await saved.click()
  107. await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
  108. await expect
  109. .poll(() => accountSecurityEntries(email, since))
  110. .toEqual(['Turned on two-factor sign-in'])
  111. })
  112. test('signing in now asks for the code before opening anything', async ({ browser }) => {
  113. const fresh = await signedOut(browser)
  114. await signIn(fresh, password)
  115. await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
  116. // Nothing behind the door without the code.
  117. await fresh.goto('/customers')
  118. await expect(fresh).toHaveURL(/\/auth\//)
  119. await fresh.goto('/auth/verify-2fa')
  120. const stored = await storedTwoFactorSecret(email)
  121. await fresh.locator('#code').fill(await currentTotpCode(stored as string))
  122. await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
  123. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  124. await fresh.context().close()
  125. })
  126. test('a backup code opens the account in place of the app, and is logged', async ({
  127. browser,
  128. }) => {
  129. const fresh = await signedOut(browser)
  130. await signIn(fresh, password)
  131. await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
  132. await fresh.getByRole('button', { name: 'Use a backup code', exact: true }).click()
  133. // A wrong code opens nothing and is not a sign-in to log.
  134. await fresh.locator('#code').fill('wrong-codes')
  135. await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
  136. await expect(fresh.getByRole('button', { name: 'Verify', exact: true })).toBeEnabled()
  137. await expect(fresh).toHaveURL(/\/auth\/verify-2fa/)
  138. await fresh.locator('#code').fill(backupCodes[0])
  139. await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
  140. await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  141. await expect
  142. .poll(() => accountSecurityEntries(email, since))
  143. .toEqual(['Turned on two-factor sign-in', 'Signed in with a backup code'])
  144. await fresh.context().close()
  145. })
  146. test('is switched off again with the password', async () => {
  147. await page.goto('/settings/account')
  148. await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
  149. await page.getByRole('button', { name: 'Disable 2FA', exact: true }).click()
  150. // Off in the page, and gone from the database.
  151. await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeVisible({
  152. timeout: 15_000,
  153. })
  154. expect(await storedTwoFactorSecret(email)).toBeNull()
  155. await expect
  156. .poll(() => accountSecurityEntries(email, since))
  157. .toEqual([
  158. 'Turned on two-factor sign-in',
  159. 'Signed in with a backup code',
  160. 'Turned off two-factor sign-in',
  161. ])
  162. })
  163. })