### Task 7: Pagina di login pubblica `/login` **Files:** - Create: `src/pages/login.astro` - Create: `src/lib/safe-next.ts` - Test: `tests/safe-next.test.ts` **Interfaces:** - Consumes: `login`, `SESSION_COOKIE` (auth.ts), `rateLimit` (rate-limit.ts). - Produces: `safeNext(next: string | null): string` — ritorna un path interno sicuro o `'/'`. - [ ] **Step 1: Test di `safeNext`** Crea `tests/safe-next.test.ts`: ```typescript import { describe, it, expect } from 'vitest'; import { safeNext } from '../src/lib/safe-next'; describe('safeNext', () => { it('accetta path interni', () => { expect(safeNext('/about')).toBe('/about'); expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1'); }); it('rifiuta esterni, protocol-relative, api e vuoti', () => { expect(safeNext(null)).toBe('/'); expect(safeNext('')).toBe('/'); expect(safeNext('//evil.com')).toBe('/'); expect(safeNext('https://evil.com')).toBe('/'); expect(safeNext('/api/admin/posts')).toBe('/'); expect(safeNext('javascript:alert(1)')).toBe('/'); expect(safeNext('not-a-path')).toBe('/'); }); }); ``` - [ ] **Step 2: Esegui e verifica il fallimento** Run: `npm test -- tests/safe-next.test.ts` Expected: FAIL — modulo inesistente. - [ ] **Step 3: Implementa `safeNext`** Crea `src/lib/safe-next.ts`: ```typescript // Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere // protocol-relative ("//"), non puntare alle API. Tutto il resto → home. export function safeNext(next: string | null): string { if (!next || !next.startsWith('/') || next.startsWith('//')) return '/'; if (next.startsWith('/api/')) return '/'; return next; } ``` - [ ] **Step 4: Esegui e verifica il successo** Run: `npm test -- tests/safe-next.test.ts` Expected: PASS. - [ ] **Step 5: Crea la pagina `/login`** Crea `src/pages/login.astro`: ```astro --- import Base from '../layouts/Base.astro'; import { getDb } from '../lib/db'; import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth'; import { rateLimit } from '../lib/rate-limit'; import { safeNext } from '../lib/safe-next'; export const prerender = false; const dest = safeNext(Astro.url.searchParams.get('next')); // Già loggato → via dal login. const existing = Astro.cookies.get(SESSION_COOKIE)?.value; if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest); let error = ''; if (Astro.request.method === 'POST') { if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) { error = 'Troppi tentativi: riprova tra 15 minuti.'; } else { const form = await Astro.request.formData(); const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? '')); if (token) { Astro.cookies.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, }); return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest)); } error = 'Credenziali non valide.'; } } ---

Accedi

{error && }
``` - [ ] **Step 6: Verifica build** Run: `npm run build` Expected: build OK. - [ ] **Step 7: Commit** ```bash git add src/pages/login.astro src/lib/safe-next.ts tests/safe-next.test.ts git commit -m "feat(login): pagina pubblica /login con next sicuro" ``` ---