Skip to content

Commit 2349663

Browse files
committed
test(e2e): aggiunge audit a11y + 2FA TOTP + lockout + sessioni; fissa label htmlFor e role=tablist
- a11y.spec.ts: nuovo audit WCAG 2.1 AA con @axe-core/playwright (filtro impact serious/critical) su homepage/support/self-care, login/register, /account autenticato - two-factor.spec.ts: attivazione 2FA con TOTP (otpauth) e login successivo con codice - lockout.spec.ts: dopo 5 tentativi falliti il login viene bloccato temporaneamente - sessions.spec.ts: la sessione corrente compare in /account - helpers.ts: aggiunge loginViaUi - LoginPage/RegisterPage/AccountPage: label con htmlFor+id per ogni input (fix violazioni axe 'Form elements must have labels') - SupportPage: role=tablist sostituito con role=group (i chip sono toggle, non tab)
1 parent 0f04e9c commit 2349663

11 files changed

Lines changed: 261 additions & 17 deletions

frontend/e2e/a11y.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test, expect } from '@playwright/test';
2+
import AxeBuilder from '@axe-core/playwright';
3+
import { newUser, registerViaUi } from './helpers';
4+
5+
/**
6+
* Smoke a11y audit on the most-used pages.
7+
* We check for "serious" and "critical" WCAG 2.1 A/AA violations only — the
8+
* project doesn't aim for full AAA conformance yet, so noisy minor findings are
9+
* filtered out to keep the gate practical.
10+
*/
11+
async function runAudit(page: import('@playwright/test').Page, label: string) {
12+
const results = await new AxeBuilder({ page })
13+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
14+
.analyze();
15+
16+
const blockers = results.violations.filter(
17+
(v) => v.impact === 'critical' || v.impact === 'serious'
18+
);
19+
20+
if (blockers.length > 0) {
21+
const summary = blockers
22+
.map(
23+
(v) =>
24+
`- [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes
25+
.slice(0, 3)
26+
.map((n) => n.target.join(' '))
27+
.join('\n ')}`
28+
)
29+
.join('\n');
30+
throw new Error(`a11y blockers on ${label}:\n${summary}`);
31+
}
32+
expect(blockers).toEqual([]);
33+
}
34+
35+
test.describe('Audit accessibilità WCAG 2.1 AA', () => {
36+
test('homepage e pagine pubbliche non hanno violazioni serious/critical', async ({ page }) => {
37+
await page.goto('/');
38+
await runAudit(page, '/');
39+
40+
await page.goto('/support');
41+
await expect(page.getByRole('heading', { name: /trov[ai] supporto/i })).toBeVisible();
42+
await runAudit(page, '/support');
43+
44+
await page.goto('/self-care');
45+
await expect(page.getByRole('heading', { name: /cura di te/i })).toBeVisible();
46+
await runAudit(page, '/self-care');
47+
});
48+
49+
test('login e register non hanno violazioni serious/critical', async ({ page }) => {
50+
await page.goto('/login');
51+
await runAudit(page, '/login');
52+
53+
await page.goto('/register');
54+
await runAudit(page, '/register');
55+
});
56+
57+
test('/account autenticato non ha violazioni serious/critical', async ({ page }) => {
58+
const user = newUser('a11y');
59+
await registerViaUi(page, user);
60+
await page.goto('/account');
61+
await expect(page.getByRole('heading', { name: /come stai oggi/i })).toBeVisible();
62+
await runAudit(page, '/account');
63+
});
64+
});

frontend/e2e/helpers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ export async function registerViaUi(page: Page, user: TestUser) {
3333
await page.getByRole('button', { name: 'Crea il mio spazio' }).click();
3434
await expect(page).not.toHaveURL(/\/register/);
3535
}
36+
37+
export async function loginViaUi(page: Page, email: string, password: string) {
38+
await page.goto('/login');
39+
const inputs = page.locator('input.input');
40+
await inputs.nth(0).fill(email);
41+
await inputs.nth(1).fill(password);
42+
await page.getByRole('button', { name: /accedi|entra/i }).click();
43+
}

frontend/e2e/lockout.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test, expect } from '@playwright/test';
2+
import { newUser, registerViaUi, loginViaUi } from './helpers';
3+
4+
test('dopo 5 tentativi falliti consecutivi il login viene temporaneamente bloccato', async ({ browser }) => {
5+
// Register a real user in one context.
6+
const owner = newUser('lockout');
7+
const ownerCtx = await browser.newContext();
8+
const ownerPage = await ownerCtx.newPage();
9+
await registerViaUi(ownerPage, owner);
10+
await ownerCtx.close();
11+
12+
// From a fresh anonymous context, fail the login 5 times.
13+
const attackerCtx = await browser.newContext();
14+
const attackerPage = await attackerCtx.newPage();
15+
16+
for (let i = 0; i < 4; i++) {
17+
await loginViaUi(attackerPage, owner.email, 'WrongPassword!!!');
18+
// Generic credenziali non valide message (not lockout yet).
19+
await expect(attackerPage).toHaveURL(/\/login/);
20+
// Wait for the error region to appear (any red text).
21+
await expect(attackerPage.locator('div.text-red-700').first()).toBeVisible();
22+
}
23+
24+
// 5th wrong attempt should trigger the lockout message.
25+
await loginViaUi(attackerPage, owner.email, 'WrongPassword!!!');
26+
await expect(attackerPage.locator('div.text-red-700').first()).toContainText(/bloccat/i);
27+
28+
// Even a correct password is refused while locked out.
29+
await loginViaUi(attackerPage, owner.email, owner.password);
30+
await expect(attackerPage).toHaveURL(/\/login/);
31+
await expect(attackerPage.locator('div.text-red-700').first()).toContainText(/bloccat/i);
32+
33+
await attackerCtx.close();
34+
});

frontend/e2e/sessions.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { test, expect } from '@playwright/test';
2+
import { newUser, registerViaUi } from './helpers';
3+
4+
test('la sessione corrente è visibile nello storico sessioni di /account', async ({ page }) => {
5+
const user = newUser('sessions');
6+
await registerViaUi(page, user);
7+
8+
await page.goto('/account');
9+
await expect(page.getByRole('heading', { name: /sessioni attive/i })).toBeVisible();
10+
11+
// The session list should contain at least one row with the "questa sessione" badge.
12+
await expect(page.getByText(/questa sessione/i)).toBeVisible();
13+
});

frontend/e2e/two-factor.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { test, expect } from '@playwright/test';
2+
import { TOTP } from 'otpauth';
3+
import { newUser, registerViaUi, loginViaUi } from './helpers';
4+
5+
/**
6+
* Builds a TOTP code from a base32 secret using the same defaults as the backend
7+
* (SHA1, 6 digits, 30s window).
8+
*/
9+
function totp(secret: string): string {
10+
return new TOTP({
11+
secret: secret.replace(/\s+/g, ''),
12+
algorithm: 'SHA1',
13+
digits: 6,
14+
period: 30
15+
}).generate();
16+
}
17+
18+
test('un caregiver può attivare la 2FA e poi accedere usando il codice TOTP', async ({ browser }) => {
19+
const setupCtx = await browser.newContext();
20+
const setupPage = await setupCtx.newPage();
21+
22+
const user = newUser('twofa');
23+
await registerViaUi(setupPage, user);
24+
25+
await setupPage.goto('/account');
26+
await expect(setupPage.getByRole('heading', { name: /autenticazione a due fattori/i })).toBeVisible();
27+
28+
// Start setup
29+
await setupPage.getByRole('button', { name: /attiva 2fa/i }).click();
30+
31+
// The secret is rendered inside a <code> element; read it back.
32+
const secretLocator = setupPage.locator('code').first();
33+
await expect(secretLocator).toBeVisible();
34+
const secret = (await secretLocator.textContent())?.trim() ?? '';
35+
expect(secret.length).toBeGreaterThan(8);
36+
37+
// Generate the current TOTP code and confirm.
38+
// The setup form lacks htmlFor on its label, so target the one-time-code input directly.
39+
const code = totp(secret);
40+
await setupPage.locator('input[autocomplete="one-time-code"]').fill(code);
41+
await setupPage.getByRole('button', { name: /conferma e attiva/i }).click();
42+
43+
// Recovery codes block appears + status flips to "enabled".
44+
await expect(setupPage.getByText(/salva i codici di recupero/i)).toBeVisible();
45+
await expect(setupPage.getByText(/autenticazione a due fattori attiva/i)).toBeVisible();
46+
47+
await setupCtx.close();
48+
49+
// Now log in from a clean context to confirm the 2FA challenge.
50+
const loginCtx = await browser.newContext();
51+
const loginPage = await loginCtx.newPage();
52+
53+
await loginViaUi(loginPage, user.email, user.password);
54+
55+
// Should land on the second-factor screen, not on home.
56+
await expect(loginPage.getByRole('heading', { name: /verifica in due passaggi/i })).toBeVisible();
57+
58+
const challengeCode = totp(secret);
59+
await loginPage.locator('input.input').first().fill(challengeCode);
60+
await loginPage.getByRole('button', { name: /verifica|conferma|entra/i }).click();
61+
62+
// Successful sign-in leaves /login.
63+
await expect(loginPage).not.toHaveURL(/\/login/);
64+
65+
await loginCtx.close();
66+
});

frontend/package-lock.json

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
"react-router-dom": "^6.28.0"
2424
},
2525
"devDependencies": {
26+
"@axe-core/playwright": "^4.11.3",
2627
"@playwright/test": "^1.60.0",
2728
"@types/react": "^18.3.12",
2829
"@types/react-dom": "^18.3.1",
2930
"@vitejs/plugin-react": "^4.3.4",
3031
"autoprefixer": "^10.4.20",
32+
"otpauth": "^9.5.1",
3133
"postcss": "^8.4.49",
3234
"tailwindcss": "^3.4.16",
3335
"typescript": "^5.7.2",

frontend/src/pages/AccountPage.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,9 @@ export default function AccountPage() {
127127
<h2 className="text-base font-semibold text-accanto-900">{t('account.changePassword')}</h2>
128128
<form onSubmit={submitPassword} className="space-y-3">
129129
<div>
130-
<label className="block text-sm text-accanto-700 mb-1">{t('account.currentPassword')}</label>
130+
<label className="block text-sm text-accanto-700 mb-1" htmlFor="account-current-pwd">{t('account.currentPassword')}</label>
131131
<input
132+
id="account-current-pwd"
132133
type="password"
133134
value={currentPwd}
134135
onChange={(e) => setCurrentPwd(e.target.value)}
@@ -138,8 +139,9 @@ export default function AccountPage() {
138139
/>
139140
</div>
140141
<div>
141-
<label className="block text-sm text-accanto-700 mb-1">{t('account.newPassword')}</label>
142+
<label className="block text-sm text-accanto-700 mb-1" htmlFor="account-new-pwd">{t('account.newPassword')}</label>
142143
<input
144+
id="account-new-pwd"
143145
type="password"
144146
value={newPwd}
145147
onChange={(e) => setNewPwd(e.target.value)}
@@ -151,8 +153,9 @@ export default function AccountPage() {
151153
<p className="text-xs text-accanto-500 mt-1">{t('account.passwordHint')}</p>
152154
</div>
153155
<div>
154-
<label className="block text-sm text-accanto-700 mb-1">{t('account.confirmNewPassword')}</label>
156+
<label className="block text-sm text-accanto-700 mb-1" htmlFor="account-new-pwd-confirm">{t('account.confirmNewPassword')}</label>
155157
<input
158+
id="account-new-pwd-confirm"
156159
type="password"
157160
value={newPwd2}
158161
onChange={(e) => setNewPwd2(e.target.value)}
@@ -207,8 +210,9 @@ export default function AccountPage() {
207210
</p>
208211
<form onSubmit={submitDelete} className="space-y-3">
209212
<div>
210-
<label className="block text-sm text-accanto-700 mb-1">{t('account.deleteConfirmLabel')}</label>
213+
<label className="block text-sm text-accanto-700 mb-1" htmlFor="account-delete-pwd">{t('account.deleteConfirmLabel')}</label>
211214
<input
215+
id="account-delete-pwd"
212216
type="password"
213217
value={deletePwd}
214218
onChange={(e) => setDeletePwd(e.target.value)}

frontend/src/pages/LoginPage.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ export default function LoginPage() {
6969
<form onSubmit={submitTwoFactor} className="space-y-4">
7070
{!useRecovery ? (
7171
<div>
72-
<label className="label">{t('auth.twoFactorCode')}</label>
72+
<label className="label" htmlFor="login-2fa-code">{t('auth.twoFactorCode')}</label>
7373
<input
74+
id="login-2fa-code"
7475
className="input"
7576
inputMode="numeric"
7677
autoComplete="one-time-code"
@@ -84,8 +85,9 @@ export default function LoginPage() {
8485
</div>
8586
) : (
8687
<div>
87-
<label className="label">{t('auth.twoFactorRecoveryCode')}</label>
88+
<label className="label" htmlFor="login-2fa-recovery">{t('auth.twoFactorRecoveryCode')}</label>
8889
<input
90+
id="login-2fa-recovery"
8991
className="input"
9092
autoComplete="off"
9193
required
@@ -115,12 +117,12 @@ export default function LoginPage() {
115117
<p className="text-accanto-500 mb-6">{t('auth.loginSubtitle')}</p>
116118
<form onSubmit={submit} className="space-y-4">
117119
<div>
118-
<label className="label">{t('auth.email')}</label>
119-
<input className="input" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
120+
<label className="label" htmlFor="login-email">{t('auth.email')}</label>
121+
<input id="login-email" className="input" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
120122
</div>
121123
<div>
122-
<label className="label">{t('auth.password')}</label>
123-
<input className="input" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
124+
<label className="label" htmlFor="login-password">{t('auth.password')}</label>
125+
<input id="login-password" className="input" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
124126
</div>
125127
{error && <div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">{error}</div>}
126128
<button className="btn-primary w-full" disabled={busy}>{busy ? t('auth.loggingIn') : t('auth.loginCta')}</button>

0 commit comments

Comments
 (0)