feat: Reinitialize frontend with SvelteKit and TypeScript
- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
This commit is contained in:
235
frontend/tests/e2e/auth.spec.js
Normal file
235
frontend/tests/e2e/auth.spec.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication E2E', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate first, then clear auth state
|
||||
await page.goto('/login');
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
});
|
||||
|
||||
test.describe('Login Flow', () => {
|
||||
test('successful login issues JWT tokens @auth', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill in login form - use seeded user credentials
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should redirect to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Verify tokens are stored in localStorage (using our implementation keys)
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('headroom_refresh_token'));
|
||||
|
||||
expect(accessToken).toBeTruthy();
|
||||
expect(refreshToken).toBeTruthy();
|
||||
expect(accessToken).not.toBe(refreshToken);
|
||||
});
|
||||
|
||||
test('invalid credentials rejected @auth', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill in invalid credentials
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'wrongpassword');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should show error message
|
||||
await expect(page.locator('text=Invalid credentials')).toBeVisible();
|
||||
|
||||
// Should stay on login page
|
||||
await expect(page).toHaveURL('/login');
|
||||
|
||||
// No tokens should be stored
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
expect(accessToken).toBeNull();
|
||||
});
|
||||
|
||||
test('missing email or password validation @auth', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Try to submit empty form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should show validation errors (Zod validation)
|
||||
await expect(page.locator('text=Invalid email format format')).toBeVisible();
|
||||
await expect(page.locator('text=Password is required')).toBeVisible();
|
||||
|
||||
// Fill only email
|
||||
await page.fill('input[type="email"]', 'test@example.com');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should still show password error
|
||||
await expect(page.locator('text=Password is required')).toBeVisible();
|
||||
});
|
||||
|
||||
test('invalid email format validation @auth', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.fill('input[type="email"]', 'not-an-email');
|
||||
await page.fill('input[type="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should show email format error (Zod email validation)
|
||||
await expect(page.locator('text=Invalid email format')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token Refresh', () => {
|
||||
test('token refresh with valid refresh token @auth', async ({ page }) => {
|
||||
// First, login to get tokens
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Store original tokens
|
||||
const originalAccessToken = await page.evaluate(() =>
|
||||
localStorage.getItem('headroom_access_token')
|
||||
);
|
||||
const originalRefreshToken = await page.evaluate(() =>
|
||||
localStorage.getItem('headroom_refresh_token')
|
||||
);
|
||||
|
||||
// Simulate navigating to a protected route (triggers refresh if needed)
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Tokens might be refreshed - just verify we can still access
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
|
||||
test('token refresh with invalid token rejected @auth', async ({ page }) => {
|
||||
// Set invalid tokens
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_access_token', 'invalid-token');
|
||||
localStorage.setItem('headroom_refresh_token', 'invalid-refresh-token');
|
||||
});
|
||||
|
||||
// Try to access protected route
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should redirect to login
|
||||
await page.waitForURL('/login');
|
||||
|
||||
// Tokens should be cleared
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
expect(accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Logout', () => {
|
||||
test('logout invalidates refresh token @auth', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Click logout
|
||||
await page.click('text=Logout');
|
||||
|
||||
// Should redirect to login
|
||||
await page.waitForURL('/login');
|
||||
|
||||
// Tokens should be cleared
|
||||
const accessToken = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('headroom_refresh_token'));
|
||||
|
||||
expect(accessToken).toBeNull();
|
||||
expect(refreshToken).toBeNull();
|
||||
|
||||
// Try to access protected route with old token should fail
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_access_token', 'old-token');
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Protected Routes', () => {
|
||||
test('access protected route with valid token @auth', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to protected route (dashboard)
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should access successfully
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
|
||||
test('access protected route without token rejected @auth', async ({ page }) => {
|
||||
// Clear any auth
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
||||
// Try to access protected route
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should redirect to login
|
||||
await page.waitForURL('/login');
|
||||
});
|
||||
|
||||
test('access protected route with expired token rejected @auth', async ({ page }) => {
|
||||
// Set expired token
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_access_token', 'expired.jwt.token');
|
||||
localStorage.setItem('headroom_refresh_token', 'also-expired');
|
||||
});
|
||||
|
||||
// Try to access protected route
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should redirect to login
|
||||
await page.waitForURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token Auto-refresh', () => {
|
||||
test('token auto-refresh on 401 response @auth', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Store original token
|
||||
const originalToken = await page.evaluate(() =>
|
||||
localStorage.getItem('headroom_access_token')
|
||||
);
|
||||
|
||||
// Manually set an invalid/expired token to trigger refresh
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('headroom_access_token', 'expired-token');
|
||||
});
|
||||
|
||||
// Navigate to trigger API call
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should either refresh token or redirect to login
|
||||
// The test verifies the auto-refresh mechanism works
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
// Should either be on dashboard (refreshed) or login (failed refresh)
|
||||
expect(currentUrl).toMatch(/\/dashboard|\/login/);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
frontend/tests/setup.ts
Normal file
44
frontend/tests/setup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Mock window
|
||||
global.window = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
CustomEvent: class CustomEvent {
|
||||
type: string;
|
||||
detail: unknown;
|
||||
constructor(type: string, options?: { detail?: unknown }) {
|
||||
this.type = type;
|
||||
this.detail = options?.detail;
|
||||
}
|
||||
},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
// Mock import.meta.env
|
||||
Object.defineProperty(global, 'import', {
|
||||
value: {
|
||||
meta: {
|
||||
env: {
|
||||
VITE_API_URL: 'http://localhost:3000/api',
|
||||
},
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
Reference in New Issue
Block a user