devices.spec.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { type BrowserContext, expect, type Page, test } from '@playwright/test'
  2. import { deviceCountFor, sessionCountFor } from '../../support/db'
  3. import { fillSettled } from '../../support/hydration'
  4. import { mailsTo, waitForMail } from '../../support/mail'
  5. /**
  6. * Who is signed in to an account, and how to get them out.
  7. *
  8. * A sign-in from a browser the account has not seen before earns a mail,
  9. * the account page lists every open session as a device the owner can
  10. * recognise, any of them can be signed out from there, and a password change
  11. * ends all the others by itself. That last one is what makes a password
  12. * change worth anything against a stolen session.
  13. */
  14. test.describe.configure({ mode: 'serial' })
  15. const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
  16. const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
  17. const stamp = Date.now()
  18. const changed = `E2e-devices-${stamp}`
  19. /** A phone: no cookies from anywhere, and a user agent the list will name. */
  20. const PHONE_UA =
  21. 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
  22. async function signIn(page: Page, secret = password) {
  23. await page.goto('/auth/sign-in')
  24. await page.locator('#email').fill(email)
  25. await page.locator('#password').fill(secret)
  26. await page.getByRole('button', { name: 'Sign In', exact: true }).click()
  27. await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
  28. }
  29. /** Whether a context's session is still honoured, asked past the cookie cache. */
  30. async function stillSignedIn(context: BrowserContext): Promise<boolean> {
  31. const response = await context.request.get('/api/public/auth/get-session?disableCookieCache=true')
  32. const body = await response.text()
  33. return body !== 'null' && body !== ''
  34. }
  35. let phone: BrowserContext
  36. test.beforeAll(async ({ browser }) => {
  37. phone = await browser.newContext({
  38. storageState: { cookies: [], origins: [] },
  39. userAgent: PHONE_UA,
  40. })
  41. })
  42. test.afterAll(async () => {
  43. await phone.close()
  44. })
  45. test('a sign-in from a new browser mails the owner', async () => {
  46. const before = Date.now()
  47. const page = await phone.newPage()
  48. await signIn(page)
  49. const mail = await waitForMail(email, { subject: /new sign-in/i })
  50. expect(mail.html).toContain('Safari on iPhone')
  51. expect(mail.html).toContain('/settings/account')
  52. expect(new Date(mail.receivedAt).getTime()).toBeGreaterThanOrEqual(before - 60_000)
  53. })
  54. test('the account page lists the phone and signs it out', async ({ page }) => {
  55. await page.goto('/settings/account')
  56. const list = page.getByTestId('signed-in-devices')
  57. const own = list.getByTestId('signed-in-device').filter({ hasText: 'This device' })
  58. await expect(own).toHaveCount(1)
  59. const iphone = list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
  60. await expect(iphone.first()).toBeVisible()
  61. const sessionsBefore = await sessionCountFor(email)
  62. await iphone
  63. .first()
  64. .getByRole('button', { name: /sign out/i })
  65. .click()
  66. await expect(page.getByText('Device signed out', { exact: true })).toBeVisible()
  67. await expect(
  68. list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
  69. ).toHaveCount(0)
  70. expect(await sessionCountFor(email)).toBe(sessionsBefore - 1)
  71. expect(await stillSignedIn(phone), 'the phone is out').toBe(false)
  72. // What the person holding the phone sees: the next page it asks for is the
  73. // sign-in page. A row count proved nothing here while the session cookie
  74. // cache let a revoked session keep working for five minutes.
  75. const held = await phone.newPage()
  76. await held.goto('/customers')
  77. await expect(held).toHaveURL(/\/auth\/sign-in/, { timeout: 15_000 })
  78. await held.close()
  79. })
  80. test('signing in again on the same phone is not a new device', async () => {
  81. // The device cookie outlives the session, so the phone that was just
  82. // signed out comes back as itself: one device row, no second mail.
  83. const mailsBefore = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
  84. const rowsBefore = await deviceCountFor(email, 'iPhone')
  85. const page = await phone.newPage()
  86. await signIn(page)
  87. await page.waitForTimeout(1_500)
  88. const mailsAfter = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
  89. expect(mailsAfter, 'no new-device mail for a device the account knows').toBe(mailsBefore)
  90. expect(await deviceCountFor(email, 'iPhone'), 'no second row for the phone').toBe(rowsBefore)
  91. await page.close()
  92. })
  93. test('changing the password ends every other device', async ({ page, browser }) => {
  94. const other = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  95. await signIn(await other.newPage())
  96. expect(await stillSignedIn(other)).toBe(true)
  97. await page.goto('/settings/account')
  98. await fillSettled(page.locator('#currentPassword'), password)
  99. await fillSettled(page.locator('#newPassword'), changed)
  100. await fillSettled(page.locator('#confirmPassword'), changed)
  101. await page.getByRole('button', { name: 'Change Password', exact: true }).click()
  102. await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
  103. expect(await stillSignedIn(other), 'the other browser is out').toBe(false)
  104. expect(await sessionCountFor(email), 'only the changing browser remains').toBe(1)
  105. const held = await other.newPage()
  106. await held.goto('/customers')
  107. await expect(held, 'the other browser lands on sign-in').toHaveURL(/\/auth\/sign-in/, {
  108. timeout: 15_000,
  109. })
  110. await other.close()
  111. // Put the seeded password back for the rest of the suite, and save the
  112. // session this browser ended up with: each change retired the one before.
  113. await fillSettled(page.locator('#currentPassword'), changed)
  114. await fillSettled(page.locator('#newPassword'), password)
  115. await fillSettled(page.locator('#confirmPassword'), password)
  116. await page.getByRole('button', { name: 'Change Password', exact: true }).click()
  117. await expect(page.getByText('Password changed', { exact: true }).first()).toBeVisible()
  118. await page.context().storageState({ path: 'e2e/.auth/owner.json' })
  119. })