You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. # Email-Based Authentication Testing
  2. ## Principle
  3. Email-based authentication (magic links, one-time codes, passwordless login) requires specialized testing with email capture services like Mailosaur or Ethereal. Extract magic links via HTML parsing or use built-in link extraction, preserve browser storage (local/session/cookies) when processing links, cache email payloads to avoid exhausting inbox quotas, and cover negative cases (expired links, reused links, multiple rapid requests). Log email IDs and links for troubleshooting, but scrub PII before committing artifacts.
  4. ## Rationale
  5. Email authentication introduces unique challenges: asynchronous email delivery, quota limits (AWS Cognito: 50/day), cost per email, and complex state management (session preservation across link clicks). Without proper patterns, tests become slow (wait for email each time), expensive (quota exhaustion), and brittle (timing issues, missing state). Using email capture services + session caching + state preservation patterns makes email auth tests fast, reliable, and cost-effective.
  6. ## Pattern Examples
  7. ### Example 1: Magic Link Extraction with Mailosaur
  8. **Context**: Passwordless login flow where user receives magic link via email, clicks it, and is authenticated.
  9. **Implementation**:
  10. ```typescript
  11. // tests/e2e/magic-link-auth.spec.ts
  12. import { test, expect } from '@playwright/test';
  13. /**
  14. * Magic Link Authentication Flow
  15. * 1. User enters email
  16. * 2. Backend sends magic link
  17. * 3. Test retrieves email via Mailosaur
  18. * 4. Extract and visit magic link
  19. * 5. Verify user is authenticated
  20. */
  21. // Mailosaur configuration
  22. const MAILOSAUR_API_KEY = process.env.MAILOSAUR_API_KEY!;
  23. const MAILOSAUR_SERVER_ID = process.env.MAILOSAUR_SERVER_ID!;
  24. /**
  25. * Extract href from HTML email body
  26. * DOMParser provides XML/HTML parsing in Node.js
  27. */
  28. function extractMagicLink(htmlString: string): string | null {
  29. const { JSDOM } = require('jsdom');
  30. const dom = new JSDOM(htmlString);
  31. const link = dom.window.document.querySelector('#magic-link-button');
  32. return link ? (link as HTMLAnchorElement).href : null;
  33. }
  34. /**
  35. * Alternative: Use Mailosaur's built-in link extraction
  36. * Mailosaur automatically parses links - no regex needed!
  37. */
  38. async function getMagicLinkFromEmail(email: string): Promise<string> {
  39. const MailosaurClient = require('mailosaur');
  40. const mailosaur = new MailosaurClient(MAILOSAUR_API_KEY);
  41. // Wait for email (timeout: 30 seconds)
  42. const message = await mailosaur.messages.get(
  43. MAILOSAUR_SERVER_ID,
  44. {
  45. sentTo: email,
  46. },
  47. {
  48. timeout: 30000, // 30 seconds
  49. },
  50. );
  51. // Mailosaur extracts links automatically - no parsing needed!
  52. const magicLink = message.html?.links?.[0]?.href;
  53. if (!magicLink) {
  54. throw new Error(`Magic link not found in email to ${email}`);
  55. }
  56. console.log(`📧 Email received. Magic link extracted: ${magicLink}`);
  57. return magicLink;
  58. }
  59. test.describe('Magic Link Authentication', () => {
  60. test('should authenticate user via magic link', async ({ page, context }) => {
  61. // Arrange: Generate unique test email
  62. const randomId = Math.floor(Math.random() * 1000000);
  63. const testEmail = `user-${randomId}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;
  64. // Act: Request magic link
  65. await page.goto('/login');
  66. await page.getByTestId('email-input').fill(testEmail);
  67. await page.getByTestId('send-magic-link').click();
  68. // Assert: Success message
  69. await expect(page.getByTestId('check-email-message')).toBeVisible();
  70. await expect(page.getByTestId('check-email-message')).toContainText('Check your email');
  71. // Retrieve magic link from email
  72. const magicLink = await getMagicLinkFromEmail(testEmail);
  73. // Visit magic link
  74. await page.goto(magicLink);
  75. // Assert: User is authenticated
  76. await expect(page.getByTestId('user-menu')).toBeVisible();
  77. await expect(page.getByTestId('user-email')).toContainText(testEmail);
  78. // Verify session storage preserved
  79. const localStorage = await page.evaluate(() => JSON.stringify(window.localStorage));
  80. expect(localStorage).toContain('authToken');
  81. });
  82. test('should handle expired magic link', async ({ page }) => {
  83. // Use pre-expired link (older than 15 minutes)
  84. const expiredLink = 'http://localhost:3000/auth/verify?token=expired-token-123';
  85. await page.goto(expiredLink);
  86. // Assert: Error message displayed
  87. await expect(page.getByTestId('error-message')).toBeVisible();
  88. await expect(page.getByTestId('error-message')).toContainText('link has expired');
  89. // Assert: User NOT authenticated
  90. await expect(page.getByTestId('user-menu')).not.toBeVisible();
  91. });
  92. test('should prevent reusing magic link', async ({ page }) => {
  93. const randomId = Math.floor(Math.random() * 1000000);
  94. const testEmail = `user-${randomId}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;
  95. // Request magic link
  96. await page.goto('/login');
  97. await page.getByTestId('email-input').fill(testEmail);
  98. await page.getByTestId('send-magic-link').click();
  99. const magicLink = await getMagicLinkFromEmail(testEmail);
  100. // Visit link first time (success)
  101. await page.goto(magicLink);
  102. await expect(page.getByTestId('user-menu')).toBeVisible();
  103. // Sign out
  104. await page.getByTestId('sign-out').click();
  105. // Try to reuse same link (should fail)
  106. await page.goto(magicLink);
  107. await expect(page.getByTestId('error-message')).toBeVisible();
  108. await expect(page.getByTestId('error-message')).toContainText('link has already been used');
  109. });
  110. });
  111. ```
  112. **Cypress equivalent with Mailosaur plugin**:
  113. ```javascript
  114. // cypress/e2e/magic-link-auth.cy.ts
  115. describe('Magic Link Authentication', () => {
  116. it('should authenticate user via magic link', () => {
  117. const serverId = Cypress.env('MAILOSAUR_SERVERID');
  118. const randomId = Cypress._.random(1e6);
  119. const testEmail = `user-${randomId}@${serverId}.mailosaur.net`;
  120. // Request magic link
  121. cy.visit('/login');
  122. cy.get('[data-cy="email-input"]').type(testEmail);
  123. cy.get('[data-cy="send-magic-link"]').click();
  124. cy.get('[data-cy="check-email-message"]').should('be.visible');
  125. // Retrieve and visit magic link
  126. cy.mailosaurGetMessage(serverId, { sentTo: testEmail })
  127. .its('html.links.0.href') // Mailosaur extracts links automatically!
  128. .should('exist')
  129. .then((magicLink) => {
  130. cy.log(`Magic link: ${magicLink}`);
  131. cy.visit(magicLink);
  132. });
  133. // Verify authenticated
  134. cy.get('[data-cy="user-menu"]').should('be.visible');
  135. cy.get('[data-cy="user-email"]').should('contain', testEmail);
  136. });
  137. });
  138. ```
  139. **Key Points**:
  140. - **Mailosaur auto-extraction**: `html.links[0].href` or `html.codes[0].value`
  141. - **Unique emails**: Random ID prevents collisions
  142. - **Negative testing**: Expired and reused links tested
  143. - **State verification**: localStorage/session checked
  144. - **Fast email retrieval**: 30 second timeout typical
  145. ---
  146. ### Example 2: State Preservation Pattern with cy.session / Playwright storageState
  147. **Context**: Cache authenticated session to avoid requesting magic link on every test.
  148. **Implementation**:
  149. ```typescript
  150. // playwright/fixtures/email-auth-fixture.ts
  151. import { test as base } from '@playwright/test';
  152. import { getMagicLinkFromEmail } from '../support/mailosaur-helpers';
  153. type EmailAuthFixture = {
  154. authenticatedUser: { email: string; token: string };
  155. };
  156. export const test = base.extend<EmailAuthFixture>({
  157. authenticatedUser: async ({ page, context }, use) => {
  158. const randomId = Math.floor(Math.random() * 1000000);
  159. const testEmail = `user-${randomId}@${process.env.MAILOSAUR_SERVER_ID}.mailosaur.net`;
  160. // Check if we have cached auth state for this email
  161. const storageStatePath = `./test-results/auth-state-${testEmail}.json`;
  162. try {
  163. // Try to reuse existing session
  164. await context.storageState({ path: storageStatePath });
  165. await page.goto('/dashboard');
  166. // Validate session is still valid
  167. const isAuthenticated = await page.getByTestId('user-menu').isVisible({ timeout: 2000 });
  168. if (isAuthenticated) {
  169. console.log(`✅ Reusing cached session for ${testEmail}`);
  170. await use({ email: testEmail, token: 'cached' });
  171. return;
  172. }
  173. } catch (error) {
  174. console.log(`📧 No cached session, requesting magic link for ${testEmail}`);
  175. }
  176. // Request new magic link
  177. await page.goto('/login');
  178. await page.getByTestId('email-input').fill(testEmail);
  179. await page.getByTestId('send-magic-link').click();
  180. // Get magic link from email
  181. const magicLink = await getMagicLinkFromEmail(testEmail);
  182. // Visit link and authenticate
  183. await page.goto(magicLink);
  184. await expect(page.getByTestId('user-menu')).toBeVisible();
  185. // Extract auth token from localStorage
  186. const authToken = await page.evaluate(() => localStorage.getItem('authToken'));
  187. // Save session state for reuse
  188. await context.storageState({ path: storageStatePath });
  189. console.log(`💾 Cached session for ${testEmail}`);
  190. await use({ email: testEmail, token: authToken || '' });
  191. },
  192. });
  193. ```
  194. **Cypress equivalent with cy.session + data-session**:
  195. ```javascript
  196. // cypress/support/commands/email-auth.js
  197. import { dataSession } from 'cypress-data-session';
  198. /**
  199. * Authenticate via magic link with session caching
  200. * - First run: Requests email, extracts link, authenticates
  201. * - Subsequent runs: Reuses cached session (no email)
  202. */
  203. Cypress.Commands.add('authViaMagicLink', (email) => {
  204. return dataSession({
  205. name: `magic-link-${email}`,
  206. // First-time setup: Request and process magic link
  207. setup: () => {
  208. cy.visit('/login');
  209. cy.get('[data-cy="email-input"]').type(email);
  210. cy.get('[data-cy="send-magic-link"]').click();
  211. // Get magic link from Mailosaur
  212. cy.mailosaurGetMessage(Cypress.env('MAILOSAUR_SERVERID'), {
  213. sentTo: email,
  214. })
  215. .its('html.links.0.href')
  216. .should('exist')
  217. .then((magicLink) => {
  218. cy.visit(magicLink);
  219. });
  220. // Wait for authentication
  221. cy.get('[data-cy="user-menu"]', { timeout: 10000 }).should('be.visible');
  222. // Preserve authentication state
  223. return cy.getAllLocalStorage().then((storage) => {
  224. return { storage, email };
  225. });
  226. },
  227. // Validate cached session is still valid
  228. validate: (cached) => {
  229. return cy.wrap(Boolean(cached?.storage));
  230. },
  231. // Recreate session from cache (no email needed)
  232. recreate: (cached) => {
  233. // Restore localStorage
  234. cy.setLocalStorage(cached.storage);
  235. cy.visit('/dashboard');
  236. cy.get('[data-cy="user-menu"]', { timeout: 5000 }).should('be.visible');
  237. },
  238. shareAcrossSpecs: true, // Share session across all tests
  239. });
  240. });
  241. ```
  242. **Usage in tests**:
  243. ```javascript
  244. // cypress/e2e/dashboard.cy.ts
  245. describe('Dashboard', () => {
  246. const serverId = Cypress.env('MAILOSAUR_SERVERID');
  247. const testEmail = `test-user@${serverId}.mailosaur.net`;
  248. beforeEach(() => {
  249. // First test: Requests magic link
  250. // Subsequent tests: Reuses cached session (no email!)
  251. cy.authViaMagicLink(testEmail);
  252. });
  253. it('should display user dashboard', () => {
  254. cy.get('[data-cy="dashboard-content"]').should('be.visible');
  255. });
  256. it('should show user profile', () => {
  257. cy.get('[data-cy="user-email"]').should('contain', testEmail);
  258. });
  259. // Both tests share same session - only 1 email consumed!
  260. });
  261. ```
  262. **Key Points**:
  263. - **Session caching**: First test requests email, rest reuse session
  264. - **State preservation**: localStorage/cookies saved and restored
  265. - **Validation**: Check cached session is still valid
  266. - **Quota optimization**: Massive reduction in email consumption
  267. - **Fast tests**: Cached auth takes seconds vs. minutes
  268. ---
  269. ### Example 3: Negative Flow Tests (Expired, Invalid, Reused Links)
  270. **Context**: Comprehensive negative testing for email authentication edge cases.
  271. **Implementation**:
  272. ```typescript
  273. // tests/e2e/email-auth-negative.spec.ts
  274. import { test, expect } from '@playwright/test';
  275. import { getMagicLinkFromEmail } from '../support/mailosaur-helpers';
  276. const MAILOSAUR_SERVER_ID = process.env.MAILOSAUR_SERVER_ID!;
  277. test.describe('Email Auth Negative Flows', () => {
  278. test('should reject expired magic link', async ({ page }) => {
  279. // Generate expired link (simulate 24 hours ago)
  280. const expiredToken = Buffer.from(
  281. JSON.stringify({
  282. email: 'test@example.com',
  283. exp: Date.now() - 24 * 60 * 60 * 1000, // 24 hours ago
  284. }),
  285. ).toString('base64');
  286. const expiredLink = `http://localhost:3000/auth/verify?token=${expiredToken}`;
  287. // Visit expired link
  288. await page.goto(expiredLink);
  289. // Assert: Error displayed
  290. await expect(page.getByTestId('error-message')).toBeVisible();
  291. await expect(page.getByTestId('error-message')).toContainText(/link.*expired|expired.*link/i);
  292. // Assert: Link to request new one
  293. await expect(page.getByTestId('request-new-link')).toBeVisible();
  294. // Assert: User NOT authenticated
  295. await expect(page.getByTestId('user-menu')).not.toBeVisible();
  296. });
  297. test('should reject invalid magic link token', async ({ page }) => {
  298. const invalidLink = 'http://localhost:3000/auth/verify?token=invalid-garbage';
  299. await page.goto(invalidLink);
  300. // Assert: Error displayed
  301. await expect(page.getByTestId('error-message')).toBeVisible();
  302. await expect(page.getByTestId('error-message')).toContainText(/invalid.*link|link.*invalid/i);
  303. // Assert: User not authenticated
  304. await expect(page.getByTestId('user-menu')).not.toBeVisible();
  305. });
  306. test('should reject already-used magic link', async ({ page, context }) => {
  307. const randomId = Math.floor(Math.random() * 1000000);
  308. const testEmail = `user-${randomId}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;
  309. // Request magic link
  310. await page.goto('/login');
  311. await page.getByTestId('email-input').fill(testEmail);
  312. await page.getByTestId('send-magic-link').click();
  313. const magicLink = await getMagicLinkFromEmail(testEmail);
  314. // Visit link FIRST time (success)
  315. await page.goto(magicLink);
  316. await expect(page.getByTestId('user-menu')).toBeVisible();
  317. // Sign out
  318. await page.getByTestId('user-menu').click();
  319. await page.getByTestId('sign-out').click();
  320. await expect(page.getByTestId('user-menu')).not.toBeVisible();
  321. // Try to reuse SAME link (should fail)
  322. await page.goto(magicLink);
  323. // Assert: Link already used error
  324. await expect(page.getByTestId('error-message')).toBeVisible();
  325. await expect(page.getByTestId('error-message')).toContainText(/already.*used|link.*used/i);
  326. // Assert: User not authenticated
  327. await expect(page.getByTestId('user-menu')).not.toBeVisible();
  328. });
  329. test('should handle rapid successive link requests', async ({ page }) => {
  330. const randomId = Math.floor(Math.random() * 1000000);
  331. const testEmail = `user-${randomId}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;
  332. // Request magic link 3 times rapidly
  333. for (let i = 0; i < 3; i++) {
  334. await page.goto('/login');
  335. await page.getByTestId('email-input').fill(testEmail);
  336. await page.getByTestId('send-magic-link').click();
  337. await expect(page.getByTestId('check-email-message')).toBeVisible();
  338. }
  339. // Only the LATEST link should work
  340. const MailosaurClient = require('mailosaur');
  341. const mailosaur = new MailosaurClient(process.env.MAILOSAUR_API_KEY);
  342. const messages = await mailosaur.messages.list(MAILOSAUR_SERVER_ID, {
  343. sentTo: testEmail,
  344. });
  345. // Should receive 3 emails
  346. expect(messages.items.length).toBeGreaterThanOrEqual(3);
  347. // Get the LATEST magic link
  348. const latestMessage = messages.items[0]; // Most recent first
  349. const latestLink = latestMessage.html.links[0].href;
  350. // Latest link works
  351. await page.goto(latestLink);
  352. await expect(page.getByTestId('user-menu')).toBeVisible();
  353. // Older links should NOT work (if backend invalidates previous)
  354. await page.getByTestId('sign-out').click();
  355. const olderLink = messages.items[1].html.links[0].href;
  356. await page.goto(olderLink);
  357. await expect(page.getByTestId('error-message')).toBeVisible();
  358. });
  359. test('should rate-limit excessive magic link requests', async ({ page }) => {
  360. const randomId = Math.floor(Math.random() * 1000000);
  361. const testEmail = `user-${randomId}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;
  362. // Request magic link 10 times rapidly (should hit rate limit)
  363. for (let i = 0; i < 10; i++) {
  364. await page.goto('/login');
  365. await page.getByTestId('email-input').fill(testEmail);
  366. await page.getByTestId('send-magic-link').click();
  367. // After N requests, should show rate limit error
  368. const errorVisible = await page
  369. .getByTestId('rate-limit-error')
  370. .isVisible({ timeout: 1000 })
  371. .catch(() => false);
  372. if (errorVisible) {
  373. console.log(`Rate limit hit after ${i + 1} requests`);
  374. await expect(page.getByTestId('rate-limit-error')).toContainText(/too many.*requests|rate.*limit/i);
  375. return;
  376. }
  377. }
  378. // If no rate limit after 10 requests, log warning
  379. console.warn('⚠️ No rate limit detected after 10 requests');
  380. });
  381. });
  382. ```
  383. **Key Points**:
  384. - **Expired links**: Test 24+ hour old tokens
  385. - **Invalid tokens**: Malformed or garbage tokens rejected
  386. - **Reuse prevention**: Same link can't be used twice
  387. - **Rapid requests**: Multiple requests handled gracefully
  388. - **Rate limiting**: Excessive requests blocked
  389. ---
  390. ### Example 4: Caching Strategy with cypress-data-session / Playwright Projects
  391. **Context**: Minimize email consumption by sharing authentication state across tests and specs.
  392. **Implementation**:
  393. ```javascript
  394. // cypress/support/commands/register-and-sign-in.js
  395. import { dataSession } from 'cypress-data-session';
  396. /**
  397. * Email Authentication Caching Strategy
  398. * - One email per test run (not per spec, not per test)
  399. * - First spec: Full registration flow (form → email → code → sign in)
  400. * - Subsequent specs: Only sign in (reuse user)
  401. * - Subsequent tests in same spec: Session already active (no sign in)
  402. */
  403. // Helper: Fill registration form
  404. function fillRegistrationForm({ fullName, userName, email, password }) {
  405. cy.intercept('POST', 'https://cognito-idp*').as('cognito');
  406. cy.contains('Register').click();
  407. cy.get('#reg-dialog-form').should('be.visible');
  408. cy.get('#first-name').type(fullName, { delay: 0 });
  409. cy.get('#last-name').type(lastName, { delay: 0 });
  410. cy.get('#email').type(email, { delay: 0 });
  411. cy.get('#username').type(userName, { delay: 0 });
  412. cy.get('#password').type(password, { delay: 0 });
  413. cy.contains('button', 'Create an account').click();
  414. cy.wait('@cognito').its('response.statusCode').should('equal', 200);
  415. }
  416. // Helper: Confirm registration with email code
  417. function confirmRegistration(email) {
  418. return cy
  419. .mailosaurGetMessage(Cypress.env('MAILOSAUR_SERVERID'), { sentTo: email })
  420. .its('html.codes.0.value') // Mailosaur auto-extracts codes!
  421. .then((code) => {
  422. cy.intercept('POST', 'https://cognito-idp*').as('cognito');
  423. cy.get('#verification-code').type(code, { delay: 0 });
  424. cy.contains('button', 'Confirm registration').click();
  425. cy.wait('@cognito');
  426. cy.contains('You are now registered!').should('be.visible');
  427. cy.contains('button', /ok/i).click();
  428. return cy.wrap(code); // Return code for reference
  429. });
  430. }
  431. // Helper: Full registration (form + email)
  432. function register({ fullName, userName, email, password }) {
  433. fillRegistrationForm({ fullName, userName, email, password });
  434. return confirmRegistration(email);
  435. }
  436. // Helper: Sign in
  437. function signIn({ userName, password }) {
  438. cy.intercept('POST', 'https://cognito-idp*').as('cognito');
  439. cy.contains('Sign in').click();
  440. cy.get('#sign-in-username').type(userName, { delay: 0 });
  441. cy.get('#sign-in-password').type(password, { delay: 0 });
  442. cy.contains('button', 'Sign in').click();
  443. cy.wait('@cognito');
  444. cy.contains('Sign out').should('be.visible');
  445. }
  446. /**
  447. * Register and sign in with email caching
  448. * ONE EMAIL PER MACHINE (cypress run or cypress open)
  449. */
  450. Cypress.Commands.add('registerAndSignIn', ({ fullName, userName, email, password }) => {
  451. return dataSession({
  452. name: email, // Unique session per email
  453. // First time: Full registration (form → email → code)
  454. init: () => register({ fullName, userName, email, password }),
  455. // Subsequent specs: Just check email exists (code already used)
  456. setup: () => confirmRegistration(email),
  457. // Always runs after init/setup: Sign in
  458. recreate: () => signIn({ userName, password }),
  459. // Share across ALL specs (one email for entire test run)
  460. shareAcrossSpecs: true,
  461. });
  462. });
  463. ```
  464. **Usage across multiple specs**:
  465. ```javascript
  466. // cypress/e2e/place-order.cy.ts
  467. describe('Place Order', () => {
  468. beforeEach(() => {
  469. cy.visit('/');
  470. cy.registerAndSignIn({
  471. fullName: Cypress.env('fullName'), // From cypress.config
  472. userName: Cypress.env('userName'),
  473. email: Cypress.env('email'), // SAME email across all specs
  474. password: Cypress.env('password'),
  475. });
  476. });
  477. it('should place order', () => {
  478. /* ... */
  479. });
  480. it('should view order history', () => {
  481. /* ... */
  482. });
  483. });
  484. // cypress/e2e/profile.cy.ts
  485. describe('User Profile', () => {
  486. beforeEach(() => {
  487. cy.visit('/');
  488. cy.registerAndSignIn({
  489. fullName: Cypress.env('fullName'),
  490. userName: Cypress.env('userName'),
  491. email: Cypress.env('email'), // SAME email - no new email sent!
  492. password: Cypress.env('password'),
  493. });
  494. });
  495. it('should update profile', () => {
  496. /* ... */
  497. });
  498. });
  499. ```
  500. **Playwright equivalent with storageState**:
  501. ```typescript
  502. // playwright.config.ts
  503. import { defineConfig } from '@playwright/test';
  504. export default defineConfig({
  505. projects: [
  506. {
  507. name: 'setup',
  508. testMatch: /global-setup\.ts/,
  509. },
  510. {
  511. name: 'authenticated',
  512. testMatch: /.*\.spec\.ts/,
  513. dependencies: ['setup'],
  514. use: {
  515. storageState: '.auth/user-session.json', // Reuse auth state
  516. },
  517. },
  518. ],
  519. });
  520. ```
  521. ```typescript
  522. // tests/global-setup.ts (runs once)
  523. import { test as setup } from '@playwright/test';
  524. import { getMagicLinkFromEmail } from './support/mailosaur-helpers';
  525. const authFile = '.auth/user-session.json';
  526. setup('authenticate via magic link', async ({ page }) => {
  527. const testEmail = process.env.TEST_USER_EMAIL!;
  528. // Request magic link
  529. await page.goto('/login');
  530. await page.getByTestId('email-input').fill(testEmail);
  531. await page.getByTestId('send-magic-link').click();
  532. // Get and visit magic link
  533. const magicLink = await getMagicLinkFromEmail(testEmail);
  534. await page.goto(magicLink);
  535. // Verify authenticated
  536. await expect(page.getByTestId('user-menu')).toBeVisible();
  537. // Save authenticated state (ONE TIME for all tests)
  538. await page.context().storageState({ path: authFile });
  539. console.log('✅ Authentication state saved to', authFile);
  540. });
  541. ```
  542. **Key Points**:
  543. - **One email per run**: Global setup authenticates once
  544. - **State reuse**: All tests use cached storageState
  545. - **cypress-data-session**: Intelligently manages cache lifecycle
  546. - **shareAcrossSpecs**: Session shared across all spec files
  547. - **Massive savings**: 500 tests = 1 email (not 500!)
  548. ---
  549. ## Email Authentication Testing Checklist
  550. Before implementing email auth tests, verify:
  551. - [ ] **Email service**: Mailosaur/Ethereal/MailHog configured with API keys
  552. - [ ] **Link extraction**: Use built-in parsing (html.links[0].href) over regex
  553. - [ ] **State preservation**: localStorage/session/cookies saved and restored
  554. - [ ] **Session caching**: cypress-data-session or storageState prevents redundant emails
  555. - [ ] **Negative flows**: Expired, invalid, reused, rapid requests tested
  556. - [ ] **Quota awareness**: One email per run (not per test)
  557. - [ ] **PII scrubbing**: Email IDs logged for debug, but scrubbed from artifacts
  558. - [ ] **Timeout handling**: 30 second email retrieval timeout configured
  559. ## Integration Points
  560. - Used in workflows: `*framework` (email auth setup), `*automate` (email auth test generation)
  561. - Related fragments: `fixture-architecture.md`, `test-quality.md`
  562. - Email services: Mailosaur (recommended), Ethereal (free), MailHog (self-hosted)
  563. - Plugins: cypress-mailosaur, cypress-data-session
  564. _Source: Email authentication blog, Murat testing toolkit, Mailosaur documentation_