google-sign-in.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { expect, type Page, test } from '@playwright/test'
  2. import {
  3. clearGoogleStandin,
  4. completeOnboarding,
  5. googleStandin,
  6. registerGoogleAccount,
  7. routeGoogleToStandin,
  8. signUpWithPassword,
  9. } from '../../support/cloud'
  10. import { markEmailVerified, personWithEmail } from '../../support/db'
  11. import { settle } from '../../support/hydration'
  12. /**
  13. * Signing in with Google, and who a Google account is allowed to become.
  14. *
  15. * Google here is `e2e/google-standin.ts`: its account chooser takes the
  16. * browser's trip to accounts.google.com, and its token endpoint takes the
  17. * server's code exchange. Each test offers the accounts it needs, with the
  18. * `email_verified` it needs.
  19. *
  20. * The rule most worth a test is account linking. A Google sign-in whose
  21. * address matches an existing password account is attached to that account,
  22. * which is what a returning customer expects. But it takes proof from both
  23. * sides. An address Google has not verified proves nothing about who is
  24. * signing in: anyone can create a Google account with somebody else's address
  25. * on it. And a password account nobody verified proves nothing about who made
  26. * it: anyone can sign up with somebody else's address and a password of their
  27. * own, and joining Google to that account would sign its real owner into a
  28. * stranger's account. Either way round, that is a way into another person's
  29. * workshop.
  30. */
  31. // Signing up and onboarding a workshop in a hook takes longer than a test.
  32. test.describe.configure({ mode: 'serial', timeout: 180_000 })
  33. // A stranger's browser, every time.
  34. test.use({ storageState: { cookies: [], origins: [] } })
  35. const stamp = Date.now()
  36. const NEWCOMER = `e2e-google-new-${stamp}@example.com`
  37. const RETURNING = `e2e-google-password-${stamp}@example.com`
  38. const OWNER = `e2e-google-owner-${stamp}@example.com`
  39. const SQUATTED = `e2e-google-squatted-${stamp}@example.com`
  40. const PASSWORD = `E2e-pass-${stamp}`
  41. test.beforeAll(async ({ browser }) => {
  42. await clearGoogleStandin()
  43. // Two people who signed up with a password before Google was offered, each
  44. // with a workshop of their own.
  45. for (const [email, workshop] of [
  46. [RETURNING, `E2E Returning Garage ${stamp}`],
  47. [OWNER, `E2E Owner Garage ${stamp}`],
  48. ]) {
  49. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  50. const page = await context.newPage()
  51. await signUpWithPassword(page, { name: 'E2E Password Person', email, password: PASSWORD })
  52. await completeOnboarding(page, workshop, { sampleData: false })
  53. await context.close()
  54. }
  55. // The returning person clicked the link in their verification mail. The
  56. // e2e database has no verification wall, so this is that click.
  57. await markEmailVerified(RETURNING)
  58. // And a password account somebody opened under an address that is not
  59. // theirs, never verified, and left at onboarding.
  60. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  61. const page = await context.newPage()
  62. await signUpWithPassword(page, { name: 'Not the owner', email: SQUATTED, password: PASSWORD })
  63. await context.close()
  64. })
  65. test.beforeEach(async ({ context }) => {
  66. await routeGoogleToStandin(context)
  67. })
  68. /** Presses "Continue with Google" and picks an account in the chooser. */
  69. async function continueWithGoogle(page: Page, from: string, email: string): Promise<void> {
  70. await page.goto(from)
  71. await settle(page)
  72. await expect(async () => {
  73. await page.getByRole('button', { name: 'Continue with Google' }).click()
  74. await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
  75. timeout: 5_000,
  76. })
  77. }).toPass({ timeout: 30_000 })
  78. await page.getByRole('button', { name: email, exact: true }).click()
  79. }
  80. test.describe('Google sign-in', () => {
  81. test('is offered on the sign-in and the sign-up page', async ({ page }) => {
  82. for (const path of ['/auth/sign-in', '/auth/sign-up']) {
  83. await page.goto(path)
  84. await expect(
  85. page.getByRole('button', { name: 'Continue with Google' }),
  86. `${path} offers it`
  87. ).toBeVisible()
  88. }
  89. })
  90. test('takes a newcomer to setting up a workshop', async ({ page }) => {
  91. await registerGoogleAccount({ email: NEWCOMER, name: 'E2E Google Newcomer' })
  92. await continueWithGoogle(page, '/auth/sign-up', NEWCOMER)
  93. await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
  94. await expect(page.getByRole('heading', { name: 'Set up your workshop' })).toBeVisible()
  95. const person = await personWithEmail(NEWCOMER)
  96. expect(person).toEqual({ users: 1, providers: ['google'], emailVerified: true })
  97. // What was asked of Google: this app's client, the chooser every time
  98. // (a workshop laptop is shared), and a code only this browser can redeem.
  99. const { authorizeRequests, tokenExchanges } = await googleStandin()
  100. const asked = authorizeRequests.at(-1)
  101. expect(asked?.client_id).toBe('e2e-google-client')
  102. expect(asked?.prompt).toBe('select_account')
  103. expect(asked?.code_challenge, 'PKCE').toBeTruthy()
  104. expect(tokenExchanges.at(-1)?.hadVerifier, 'the verifier came with the code').toBe(true)
  105. await completeOnboarding(page, `E2E Google Garage ${stamp}`, { sampleData: false })
  106. })
  107. test('brings the same person back to their workshop next time', async ({ page }) => {
  108. await continueWithGoogle(page, '/auth/sign-in', NEWCOMER)
  109. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  110. await expect(page.getByText(`E2E Google Garage ${stamp}`).first()).toBeVisible()
  111. expect((await personWithEmail(NEWCOMER)).users, 'still one person').toBe(1)
  112. })
  113. test('joins a password account whose address Google has verified', async ({ page }) => {
  114. await registerGoogleAccount({ email: RETURNING, emailVerified: true })
  115. await continueWithGoogle(page, '/auth/sign-in', RETURNING)
  116. // Into the workshop they already had, not into a second onboarding.
  117. await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
  118. await expect(page.getByText(`E2E Returning Garage ${stamp}`).first()).toBeVisible()
  119. const person = await personWithEmail(RETURNING)
  120. expect(person.users, 'one person, not two with the same address').toBe(1)
  121. expect(person.providers).toEqual(['credential', 'google'])
  122. })
  123. test('does not join a password account whose address was never verified', async ({ page }) => {
  124. // Google vouches for the person at the keyboard; the password account
  125. // under that address was made by somebody else. Joining the two would
  126. // sign the real owner of the address into the stranger's account.
  127. await registerGoogleAccount({ email: SQUATTED, emailVerified: true })
  128. await continueWithGoogle(page, '/auth/sign-in', SQUATTED)
  129. await page.waitForURL(/\/auth\/sign-in\?error=account_not_linked/, { timeout: 30_000 })
  130. await expect(page.getByText(/address has not been verified yet/)).toBeVisible()
  131. await expect(page.locator('#email'), 'the password form is the way in').toBeVisible()
  132. const person = await personWithEmail(SQUATTED)
  133. expect(person.users, 'no second person was made either').toBe(1)
  134. expect(person.providers, 'the password account stands alone').toEqual(['credential'])
  135. expect(person.emailVerified).toBe(false)
  136. })
  137. test('does not hand an account to a Google address nobody verified', async ({ page }) => {
  138. // Somebody made a Google account with the owner's address on it. Google
  139. // says so: the address is not verified.
  140. await registerGoogleAccount({ email: OWNER, name: 'Not the owner', emailVerified: false })
  141. await continueWithGoogle(page, '/auth/sign-in', OWNER)
  142. // Wherever the attempt ends up, it must not be inside the owner's workshop.
  143. await page.waitForLoadState('networkidle')
  144. await expect(
  145. page.getByText(`E2E Owner Garage ${stamp}`),
  146. 'the owner workshop is not opened'
  147. ).toHaveCount(0)
  148. await expect(page).toHaveURL(/\/auth\//)
  149. const person = await personWithEmail(OWNER)
  150. expect(person.providers, 'no Google account attached to the owner').toEqual(['credential'])
  151. expect(person.users).toBe(1)
  152. })
  153. test('comes back to sign-in, with a way forward, when the person turns back at Google', async ({
  154. page,
  155. }) => {
  156. await page.goto('/auth/sign-in')
  157. await settle(page)
  158. await expect(async () => {
  159. await page.getByRole('button', { name: 'Continue with Google' }).click()
  160. await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
  161. timeout: 5_000,
  162. })
  163. }).toPass({ timeout: 30_000 })
  164. await page.getByRole('link', { name: 'Cancel' }).click()
  165. await page.waitForURL(/\/auth\/sign-in/, { timeout: 30_000 })
  166. await expect(page.getByText(/Google sign-in did not complete/)).toBeVisible()
  167. // And the password form is still there to use.
  168. await expect(page.locator('#email')).toBeVisible()
  169. })
  170. })