feat(frontend): add forgot-password and reset-password pages
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m7s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 14m54s
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m7s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 14m54s
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
- /forgot-password: email form → sends POST /api/auth/forgot-password → success banner - /reset-password: password form reads token from URL → sends POST /api/auth/reset-password - Login page: add "Passwort vergessen?" link - hooks.server.ts: add /forgot-password and /reset-password to PUBLIC_PATHS; skip auth injection for public auth API endpoints - errors.ts: add INVALID_RESET_TOKEN error code - i18n: add all new message keys in de/en/es - playwright.config.ts: use E2E_BASE_URL for webServer check URL (allows reusing docker dev server at port 5173 locally) - ci.yml: pass E2E_BACKEND_URL=http://localhost:8080 to E2E test step - e2e/password-reset.spec.ts: 5 tests (4 pass locally, full flow requires e2e profile in CI) - Regenerated OpenAPI types including new /api/auth/* endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
113
frontend/e2e/password-reset.spec.ts
Normal file
113
frontend/e2e/password-reset.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Password-reset E2E tests.
|
||||
*
|
||||
* These tests run WITHOUT a stored session because they test unauthenticated flows.
|
||||
*
|
||||
* They rely on the "e2e" Spring profile being active in CI (see playwright.config.ts /
|
||||
* docker-compose.e2e.yml). The profile exposes GET /api/auth/reset-token-for-test?email=
|
||||
* so we can retrieve the generated token without a real mail server.
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// The backend is accessible directly for E2E helper calls (no SvelteKit proxy needed).
|
||||
const BACKEND_URL = process.env.E2E_BACKEND_URL ?? 'http://localhost:8080';
|
||||
|
||||
async function getResetToken(email: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/auth/reset-token-for-test?email=${encodeURIComponent(email)}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`Could not retrieve reset token for ${email}: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
test.describe('Password reset', () => {
|
||||
test('forgot-password page is accessible without login', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
await expect(page).toHaveURL('/forgot-password');
|
||||
await expect(page.getByRole('heading', { name: /Passwort vergessen/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-form.png' });
|
||||
});
|
||||
|
||||
test('forgot-password shows success banner for any email (prevents user enumeration)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel(/E-Mail/i).fill('nonexistent@example.com');
|
||||
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||
// Always shows success — never reveals whether the email exists
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-success-banner.png' });
|
||||
});
|
||||
|
||||
test('full password reset flow', async ({ page }) => {
|
||||
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
|
||||
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
const newPassword = 'NewP@ssw0rd_E2E!';
|
||||
|
||||
// 1. Request reset
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel(/E-Mail/i).fill(testEmail);
|
||||
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
|
||||
// 2. Fetch the token via the test helper endpoint
|
||||
const token = await getResetToken(testEmail);
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
// 3. Open the reset-password page with the token
|
||||
await page.goto(`/reset-password?token=${token}`);
|
||||
await expect(page.getByRole('heading', { name: /Neues Passwort/i })).toBeVisible();
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill(newPassword);
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill(newPassword);
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
|
||||
// 4. Success banner — then navigate to login
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-changed.png' });
|
||||
await page.getByRole('link', { name: /Zurück zum Login/i }).click();
|
||||
|
||||
// 5. Log in with new password
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||
await page.getByLabel('Passwort').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
|
||||
// 6. Restore original password via profile page
|
||||
await page.goto('/profile');
|
||||
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||
await page.locator('button[type="submit"]').last().click();
|
||||
// After changing password, auth_token is stale → redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
// 7. Log back in with original password to confirm restore worked
|
||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||
await page.getByLabel('Passwort').fill(originalPassword);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-restored.png' });
|
||||
});
|
||||
|
||||
test('reset-password page shows error for invalid token', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=invalidtoken000');
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill('somepassword');
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill('somepassword');
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-invalid-token.png' });
|
||||
});
|
||||
|
||||
test('reset-password page shows mismatch error when passwords differ', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=anytoken');
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill('password1');
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill('password2');
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-mismatch.png' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user