mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
361 lines
11 KiB
Markdown
361 lines
11 KiB
Markdown
|
|
# Complex Authentication Flow Patterns
|
||
|
|
|
||
|
|
## Table of Contents
|
||
|
|
|
||
|
|
1. [Email Verification Flows](#email-verification-flows)
|
||
|
|
2. [Password Reset](#password-reset)
|
||
|
|
3. [Session Timeout](#session-timeout)
|
||
|
|
4. [Remember Me Persistence](#remember-me-persistence)
|
||
|
|
5. [Logout Patterns](#logout-patterns)
|
||
|
|
6. [Tips](#tips)
|
||
|
|
7. [Related](#related)
|
||
|
|
|
||
|
|
> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Email Verification Flows
|
||
|
|
|
||
|
|
### Capturing Verification Tokens
|
||
|
|
|
||
|
|
Intercept API responses to capture verification tokens for testing:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('completes registration with email verification', async ({ page }) => {
|
||
|
|
let capturedToken = '';
|
||
|
|
|
||
|
|
await page.route('**/api/auth/register', async (route) => {
|
||
|
|
const response = await route.fetch();
|
||
|
|
const body = await response.json();
|
||
|
|
capturedToken = body.verificationToken;
|
||
|
|
await route.fulfill({ response });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/register');
|
||
|
|
await page.getByLabel('Name').fill('New User');
|
||
|
|
await page.getByLabel('Email').fill('newuser@test.com');
|
||
|
|
await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
|
||
|
|
await page.getByLabel('Confirm password').fill('SecurePass!');
|
||
|
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||
|
|
|
||
|
|
expect(capturedToken).toBeTruthy();
|
||
|
|
await page.goto(`/verify?token=${capturedToken}`);
|
||
|
|
|
||
|
|
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Fully Mocked Verification
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('verifies email with mocked endpoints', async ({ page }) => {
|
||
|
|
const mockToken = 'test-verification-abc123';
|
||
|
|
|
||
|
|
await page.route('**/api/auth/register', async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ verified: true }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/register');
|
||
|
|
await page.getByLabel('Email').fill('test@example.com');
|
||
|
|
await page.getByLabel('Password', { exact: true }).fill('Password123!');
|
||
|
|
await page.getByRole('button', { name: 'Sign up' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||
|
|
|
||
|
|
await page.goto(`/verify?token=${mockToken}`);
|
||
|
|
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Password Reset
|
||
|
|
|
||
|
|
### Complete Reset Flow
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('resets password through email link', async ({ page }) => {
|
||
|
|
let resetToken = '';
|
||
|
|
|
||
|
|
await page.route('**/api/auth/forgot-password', async (route) => {
|
||
|
|
const response = await route.fetch();
|
||
|
|
const body = await response.json();
|
||
|
|
resetToken = body.resetToken;
|
||
|
|
await route.fulfill({ response });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/forgot-password');
|
||
|
|
await page.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page.getByRole('button', { name: 'Send link' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText('Reset email sent')).toBeVisible();
|
||
|
|
|
||
|
|
expect(resetToken).toBeTruthy();
|
||
|
|
await page.goto(`/reset-password?token=${resetToken}`);
|
||
|
|
|
||
|
|
await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
|
||
|
|
await page.getByLabel('Confirm password').fill('NewPassword456!');
|
||
|
|
await page.getByRole('button', { name: 'Update password' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText('Password updated')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Expired Token Handling
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('shows error for expired reset token', async ({ page }) => {
|
||
|
|
await page.goto('/reset-password?token=expired-token');
|
||
|
|
|
||
|
|
await page.getByLabel('New password', { exact: true }).fill('NewPass!');
|
||
|
|
await page.getByLabel('Confirm password').fill('NewPass!');
|
||
|
|
await page.getByRole('button', { name: 'Update password' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Password Strength Validation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('enforces password requirements on reset', async ({ page }) => {
|
||
|
|
await page.goto('/reset-password?token=valid-token');
|
||
|
|
|
||
|
|
await page.getByLabel('New password', { exact: true }).fill('weak');
|
||
|
|
await page.getByLabel('Confirm password').fill('weak');
|
||
|
|
await page.getByRole('button', { name: 'Update password' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Session Timeout
|
||
|
|
|
||
|
|
### Detecting Expired Sessions
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('redirects to signin after session expires', async ({ page, context }) => {
|
||
|
|
await page.goto('/signin');
|
||
|
|
await page.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page.getByLabel('Password').fill('Password!');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await expect(page).toHaveURL('/home');
|
||
|
|
|
||
|
|
const cookies = await context.cookies();
|
||
|
|
const sessionCookie = cookies.find((c) => c.name.includes('session'));
|
||
|
|
|
||
|
|
if (sessionCookie) {
|
||
|
|
await context.clearCookies({ name: sessionCookie.name });
|
||
|
|
}
|
||
|
|
|
||
|
|
await page.goto('/profile');
|
||
|
|
await expect(page).toHaveURL(/\/signin/);
|
||
|
|
await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Session Extension Warning
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('shows warning before session expires', async ({ page }) => {
|
||
|
|
await page.route('**/api/auth/session', async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/home');
|
||
|
|
|
||
|
|
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
|
||
|
|
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Session Extension Action
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('extends session when user clicks extend', async ({ page }) => {
|
||
|
|
let sessionExtended = false;
|
||
|
|
|
||
|
|
await page.route('**/api/auth/session', async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.route('**/api/auth/refresh', async (route) => {
|
||
|
|
sessionExtended = true;
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ valid: true, expiresIn: 3600 }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/home');
|
||
|
|
|
||
|
|
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
|
||
|
|
timeout: 10000,
|
||
|
|
});
|
||
|
|
await page.getByRole('button', { name: /extend|stay signed in/i }).click();
|
||
|
|
|
||
|
|
expect(sessionExtended).toBe(true);
|
||
|
|
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Remember Me Persistence
|
||
|
|
|
||
|
|
### Persistent Session
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('persists session with remember me enabled', async ({ browser }) => {
|
||
|
|
const ctx1 = await browser.newContext();
|
||
|
|
const page1 = await ctx1.newPage();
|
||
|
|
|
||
|
|
await page1.goto('/signin');
|
||
|
|
await page1.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page1.getByLabel('Password').fill('Password!');
|
||
|
|
await page1.getByLabel('Keep me signed in').check();
|
||
|
|
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
|
||
|
|
await expect(page1).toHaveURL('/home');
|
||
|
|
|
||
|
|
const state = await ctx1.storageState();
|
||
|
|
await ctx1.close();
|
||
|
|
|
||
|
|
const ctx2 = await browser.newContext({ storageState: state });
|
||
|
|
const page2 = await ctx2.newPage();
|
||
|
|
|
||
|
|
await page2.goto('/home');
|
||
|
|
await expect(page2).toHaveURL('/home');
|
||
|
|
await expect(page2.getByText('Welcome')).toBeVisible();
|
||
|
|
|
||
|
|
await ctx2.close();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Session-Only Login
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('session-only login does not persist across browser restarts', async ({ browser }) => {
|
||
|
|
const ctx1 = await browser.newContext();
|
||
|
|
const page1 = await ctx1.newPage();
|
||
|
|
|
||
|
|
await page1.goto('/signin');
|
||
|
|
await page1.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page1.getByLabel('Password').fill('Password!');
|
||
|
|
// Leave "Remember me" unchecked
|
||
|
|
await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
|
||
|
|
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
|
||
|
|
await expect(page1).toHaveURL('/home');
|
||
|
|
|
||
|
|
// Only keep persistent cookies (filter out session cookies)
|
||
|
|
const cookies = await ctx1.cookies();
|
||
|
|
await ctx1.close();
|
||
|
|
|
||
|
|
const persistentCookies = cookies.filter((c) => c.expires > 0);
|
||
|
|
const ctx2 = await browser.newContext();
|
||
|
|
await ctx2.addCookies(persistentCookies);
|
||
|
|
const page2 = await ctx2.newPage();
|
||
|
|
|
||
|
|
await page2.goto('/home');
|
||
|
|
|
||
|
|
// Should redirect to login since session was not persisted
|
||
|
|
await expect(page2).toHaveURL(/\/signin/);
|
||
|
|
|
||
|
|
await ctx2.close();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Logout Patterns
|
||
|
|
|
||
|
|
### Standard Logout with Session Cleanup
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test.use({ storageState: '.auth/user.json' });
|
||
|
|
|
||
|
|
test('logs out and clears session', async ({ page, context }) => {
|
||
|
|
await page.goto('/home');
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: /account|menu/i }).click();
|
||
|
|
await page.getByRole('menuitem', { name: 'Sign out' }).click();
|
||
|
|
|
||
|
|
await expect(page).toHaveURL('/signin');
|
||
|
|
|
||
|
|
const cookies = await context.cookies();
|
||
|
|
const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
|
||
|
|
expect(sessionCookies).toHaveLength(0);
|
||
|
|
|
||
|
|
await page.goto('/home');
|
||
|
|
await expect(page).toHaveURL(/\/signin/);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Logout from All Devices
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('logs out from all devices', async ({ page }) => {
|
||
|
|
let logoutAllCalled = false;
|
||
|
|
|
||
|
|
await page.route('**/api/auth/logout-all', async (route) => {
|
||
|
|
logoutAllCalled = true;
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ message: 'Logged out everywhere' }),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/settings/security');
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Sign out everywhere' }).click();
|
||
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
||
|
|
|
||
|
|
expect(logoutAllCalled).toBe(true);
|
||
|
|
await expect(page).toHaveURL(/\/signin/);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Tips
|
||
|
|
|
||
|
|
1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests
|
||
|
|
2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens
|
||
|
|
3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared
|
||
|
|
4. **Test the full flow end-to-end** — Password reset should verify login with new password works
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login
|
||
|
|
- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures
|
||
|
|
- [third-party.md](./third-party.md) — Mocking external auth providers
|