From e4e7d94043f6ecdb6929804fd502ad149f3c70a5 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 7 Mar 2026 17:11:25 +0100 Subject: [PATCH] [UX] Standardizzazione login/register/onboarding + Test Runner v2 login.html: eye toggle, forgot password, auth-terms footer register.html: wizard 3-step, 5 ruoli NIS2, invite_token URL, P.IVA lookup onboarding.html: Font Awesome, brand color cyan (#06B6D4) test-runner.php: L1-L5 test levels, SIM-06 B2B, tab Coverage/Stats, DB row counts, run history (localStorage), 5 tabs totali Co-Authored-By: Claude Sonnet 4.6 --- public/login.html | 40 +- public/onboarding.html | 23 +- public/register.html | 563 ++++++++++++++++++++++---- public/test-runner.php | 870 ++++++++++++++++++++++++++++++----------- 4 files changed, 1182 insertions(+), 314 deletions(-) diff --git a/public/login.html b/public/login.html index 885d70d..e5d0f5f 100644 --- a/public/login.html +++ b/public/login.html @@ -5,6 +5,28 @@ Accedi - NIS2 Agile + +
@@ -34,18 +56,32 @@
- +
+ + +
+ + Password dimenticata? +
diff --git a/public/onboarding.html b/public/onboarding.html index 9d92d1c..a3fe29a 100644 --- a/public/onboarding.html +++ b/public/onboarding.html @@ -5,7 +5,10 @@ Onboarding - NIS2 Agile + @@ -41,43 +150,90 @@ NIS2 Agile -

Scegli il tuo profilo

+

Crea il tuo account

-
-
-
+ +
+
+
1
+
Ruolo
+
+
+
+
2
+
Dati
+
+
+
+
3
+
Fatto
+
- +
-
-
-
🏢
-
Azienda
-
Porto la mia organizzazione in compliance NIS2
+
+
+
+
CISO / Compliance Manager
+
Responsabile sicurezza e conformità NIS2 dell'organizzazione
-
-
👤
-
Consulente / CISO
-
Gestisco la compliance di più aziende clienti
+
+
+
Legale Rappresentante / Admin
+
Amministratore e legale rappresentante dell'organizzazione
+
+
+
+
Consulente Cybersecurity
+
Gestisco la compliance NIS2 per più aziende clienti
+
+
+
+
Auditor / Revisore
+
Verifico e valuto l'implementazione dei controlli di sicurezza
+
+
+
+
Board Member / DPO
+
Membro del consiglio o Responsabile della Protezione dei Dati con visibilità strategica
-
-
- - +
+
+ + +
+
+ + +
@@ -86,10 +242,24 @@ placeholder="nome@azienda.it" autocomplete="email" required>
+
+ + +
+
+
- +
+ + +
@@ -103,13 +273,19 @@
- +
+ + +
+ + +
+
+
+
Account creato!
+
+ Il tuo account NIS2 Agile è pronto. Stai per essere reindirizzato... +
+ +
+
@@ -132,37 +327,208 @@ window.location.href = 'dashboard.html'; } - let selectedUserType = null; + // --- State --- + let selectedRole = null; + let inviteValid = false; + let inviteToken = null; + let pivaData = null; + let pivaLookupTimer = null; + let successRedirect = 'onboarding.html'; - function selectProfile(type) { - selectedUserType = type; - document.querySelectorAll('.profile-card').forEach(c => c.classList.remove('selected')); - document.getElementById('card-' + type).classList.add('selected'); - document.getElementById('btn-next').disabled = false; + // --- URL Params pre-fill --- + const params = new URLSearchParams(window.location.search); + if (params.get('role')) { + // Will apply after DOM is ready (in DOMContentLoaded equivalent below) + document.addEventListener('DOMContentLoaded', () => { + const r = params.get('role'); + if (document.getElementById('card-' + r)) selectRole(r); + }); + } + if (params.get('invite_token')) { + inviteToken = params.get('invite_token'); + document.addEventListener('DOMContentLoaded', () => { + const details = document.getElementById('invite-details'); + details.open = true; + const inp = document.getElementById('invite-code'); + inp.value = inviteToken; + validateInvite(inviteToken); + }); + } + + // --- Role Selection --- + function selectRole(role) { + selectedRole = role; + document.querySelectorAll('.role-card').forEach(c => c.classList.remove('selected')); + const card = document.getElementById('card-' + role); + if (card) card.classList.add('selected'); + document.getElementById('btn-next-0').disabled = false; + + // Hide P.IVA for consultant role + const pivaGroup = document.getElementById('piva-group'); + pivaGroup.style.display = (role === 'consultant') ? 'none' : 'block'; + } + + // Pre-fill from URL after DOM ready + (function() { + const r = params.get('role'); + if (r && document.getElementById('card-' + r)) selectRole(r); + const tok = params.get('invite_token'); + if (tok) { + inviteToken = tok; + document.getElementById('invite-details').open = true; + document.getElementById('invite-code').value = tok; + validateInvite(tok); + } + // Pre-fill name/email + if (params.get('nome')) document.getElementById('firstname').value = params.get('nome'); + if (params.get('cognome')) document.getElementById('lastname').value = params.get('cognome'); + if (params.get('email')) document.getElementById('email').value = params.get('email'); + })(); + + // --- Invite Code Validation --- + let inviteTimer = null; + function validateInvite(val) { + const statusEl = document.getElementById('invite-status'); + clearTimeout(inviteTimer); + val = val.trim(); + if (!val) { + inviteValid = false; + inviteToken = null; + statusEl.innerHTML = ''; + return; + } + statusEl.className = 'lookup-status loading'; + statusEl.innerHTML = ' Verifica codice...'; + inviteTimer = setTimeout(async () => { + try { + const res = await fetch(api.baseUrl + '/auth/validate-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite_token: val }) + }); + const data = await res.json(); + if (data.success) { + inviteValid = true; + inviteToken = val; + statusEl.className = 'lookup-status ok'; + statusEl.innerHTML = ' Codice valido — accesso B2B confermato'; + // Pre-fill role if provided + if (data.role && document.getElementById('card-' + data.role)) { + selectRole(data.role); + } + // Pre-fill name/email from invite + if (data.nome) document.getElementById('firstname').value = data.nome; + if (data.cognome) document.getElementById('lastname').value = data.cognome; + if (data.email) document.getElementById('email').value = data.email; + } else { + inviteValid = false; + inviteToken = null; + statusEl.className = 'lookup-status err'; + statusEl.innerHTML = ' Codice non valido'; + } + } catch { + // Network error: accept token optimistically, validate on submit + inviteValid = false; + statusEl.className = 'lookup-status err'; + statusEl.innerHTML = ' Impossibile verificare'; + } + }, 600); + } + + // --- P.IVA Lookup --- + function lookupPiva(val) { + clearTimeout(pivaLookupTimer); + val = val.replace(/\s/g, ''); + const statusEl = document.getElementById('piva-status'); + if (val.length !== 11) { + pivaData = null; + statusEl.innerHTML = ''; + return; + } + statusEl.className = 'lookup-status loading'; + statusEl.innerHTML = ' Ricerca azienda...'; + pivaLookupTimer = setTimeout(async () => { + try { + const res = await fetch(api.baseUrl + '/onboarding/fetch-company', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + (api.getToken ? api.getToken() : '') + }, + body: JSON.stringify({ vat_number: val }) + }); + const data = await res.json(); + if (data.success && data.data && data.data.company_name) { + pivaData = data.data; + statusEl.className = 'lookup-status ok'; + statusEl.innerHTML = ' ' + data.data.company_name; + } else { + pivaData = null; + statusEl.className = 'lookup-status'; + statusEl.innerHTML = ' Azienda non trovata, potrai inserire i dati manualmente'; + } + } catch { + pivaData = null; + statusEl.innerHTML = ''; + } + }, 800); + } + + // --- Step Navigation --- + function setStep(n) { + [0, 1, 2].forEach(i => { + document.getElementById('step-' + i).style.display = (i === n) ? 'block' : 'none'; + const si = document.getElementById('si-' + i); + si.className = 'step-item' + (i < n ? ' done' : i === n ? ' active' : ''); + if (i < n) si.querySelector('.step-circle').innerHTML = ''; + else si.querySelector('.step-circle').textContent = i + 1; + }); + document.getElementById('conn-0').className = 'step-connector' + (n > 0 ? ' done' : ''); + document.getElementById('conn-1').className = 'step-connector' + (n > 1 ? ' done' : ''); + if (n === 2) document.getElementById('step-indicator').style.display = 'none'; + else document.getElementById('step-indicator').style.display = 'flex'; } function goToStep1() { - if (!selectedUserType) return; - document.getElementById('step-0').style.display = 'none'; - document.getElementById('step-1').style.display = 'block'; - document.getElementById('dot-0').classList.replace('active', 'done'); - document.getElementById('dot-1').classList.add('active'); - const labels = { azienda: 'Crea il tuo account aziendale', consultant: 'Crea il tuo account da Consulente' }; - document.getElementById('auth-subtitle').textContent = labels[selectedUserType]; + if (!selectedRole) return; + document.getElementById('register-error').classList.remove('visible'); + const labels = { + compliance_manager: 'Dati account — CISO / Compliance Manager', + org_admin: 'Dati account — Legale Rappresentante', + consultant: 'Dati account — Consulente Cybersecurity', + auditor: 'Dati account — Auditor / Revisore', + board_member: 'Dati account — Board Member / DPO' + }; + document.getElementById('auth-subtitle').textContent = labels[selectedRole] || 'Dati account'; + setStep(1); + document.getElementById('firstname').focus(); } function goToStep0() { - document.getElementById('step-1').style.display = 'none'; - document.getElementById('step-0').style.display = 'block'; - document.getElementById('dot-1').classList.remove('active'); - document.getElementById('dot-0').className = 'step-dot active'; - document.getElementById('auth-subtitle').textContent = 'Scegli il tuo profilo'; + document.getElementById('register-error').classList.remove('visible'); + document.getElementById('auth-subtitle').textContent = 'Crea il tuo account'; + setStep(0); } - // Password Strength - const passwordInput = document.getElementById('password'); - passwordInput.addEventListener('input', () => { - updateStrengthUI(calcPasswordStrength(passwordInput.value)); + function goToDashboard() { + window.location.href = successRedirect; + } + + // --- Password toggle --- + function togglePw(id, btn) { + const inp = document.getElementById(id); + if (inp.type === 'password') { + inp.type = 'text'; + btn.innerHTML = ''; + } else { + inp.type = 'password'; + btn.innerHTML = ''; + } + } + + // --- Password strength --- + document.getElementById('password').addEventListener('input', function() { + updateStrengthUI(calcPasswordStrength(this.value), this.value); }); function calcPasswordStrength(pw) { @@ -175,30 +541,38 @@ return Math.min(4, Math.max(1, s <= 1 ? 1 : s === 2 ? 2 : s === 3 ? 3 : 4)); } - function updateStrengthUI(level) { + function updateStrengthUI(level, pw) { const labels = { 1: 'Debole', 2: 'Sufficiente', 3: 'Buona', 4: 'Forte' }; const classes = { 1: 'weak', 2: 'fair', 3: 'good', 4: 'strong' }; for (let i = 1; i <= 4; i++) { const seg = document.getElementById('ps-' + i); seg.className = 'password-strength-segment'; - if (i <= level && passwordInput.value.length > 0) seg.classList.add('active', classes[level]); + if (i <= level && pw.length > 0) seg.classList.add('active', classes[level]); } - document.getElementById('ps-text').textContent = passwordInput.value.length > 0 ? labels[level] : ''; + document.getElementById('ps-text').textContent = pw.length > 0 ? labels[level] : ''; } - // Form Submit + // --- Form Submit --- document.getElementById('register-form').addEventListener('submit', async (e) => { e.preventDefault(); const errorEl = document.getElementById('register-error'); errorEl.classList.remove('visible'); - const fullname = document.getElementById('fullname').value.trim(); + const firstname = document.getElementById('firstname').value.trim(); + const lastname = document.getElementById('lastname').value.trim(); const email = document.getElementById('email').value.trim(); + const piva = document.getElementById('piva').value.trim(); const password = document.getElementById('password').value; const passwordConfirm = document.getElementById('password-confirm').value; + const fullname = firstname + ' ' + lastname; - if (!fullname || !email || !password || !passwordConfirm) { - errorEl.textContent = 'Tutti i campi sono obbligatori.'; + if (!firstname || !lastname || !email || !password) { + errorEl.textContent = 'Nome, cognome, email e password sono obbligatori.'; + errorEl.classList.add('visible'); + return; + } + if (selectedRole !== 'consultant' && !piva) { + errorEl.textContent = 'Inserisci la Partita IVA della tua azienda.'; errorEl.classList.add('visible'); return; } @@ -218,10 +592,49 @@ btn.textContent = 'Registrazione in corso...'; try { - const result = await api.register(email, password, fullname, selectedUserType); + let result; + + if (inviteToken) { + // B2B provision flow + result = await fetch(api.baseUrl + '/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, password, full_name: fullname, + role: selectedRole, + invite_token: inviteToken, + vat_number: piva || undefined + }) + }).then(r => r.json()); + } else { + result = await api.register(email, password, fullname, selectedRole); + } + if (result.success) { - showNotification('Account creato con successo!', 'success'); - setTimeout(() => { window.location.href = 'onboarding.html'; }, 500); + // Determine redirect + if (inviteToken) { + successRedirect = 'dashboard.html'; + } else if (selectedRole === 'consultant') { + successRedirect = 'companies.html'; + } else { + successRedirect = 'onboarding.html'; + } + + // Success step + const titles = { + consultant: 'Account Consulente creato!', + board_member: 'Account Board Member creato!', + }; + const descs = { + consultant: 'Puoi ora gestire i tuoi clienti dalla sezione Aziende.', + org_admin: pivaData ? 'Abbiamo trovato ' + pivaData.company_name + '. Completa l\'onboarding per configurare la tua organizzazione.' : 'Completa l\'onboarding per configurare la tua organizzazione NIS2.', + }; + document.getElementById('success-title').textContent = titles[selectedRole] || 'Account creato!'; + document.getElementById('success-desc').textContent = descs[selectedRole] || 'Il tuo account NIS2 Agile è pronto. Stai per essere reindirizzato...'; + document.getElementById('auth-subtitle').textContent = 'Registrazione completata'; + + setStep(2); + setTimeout(goToDashboard, 2500); } else { errorEl.textContent = result.message || 'Errore durante la registrazione.'; errorEl.classList.add('visible'); diff --git a/public/test-runner.php b/public/test-runner.php index 7975885..37e027b 100644 --- a/public/test-runner.php +++ b/public/test-runner.php @@ -1,9 +1,15 @@ /dev/null"; + $getToken = "\$({$loginAdmin} | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('access_token',''))\" 2>/dev/null)"; + return [ + // ── INFRASTRUCTURE ────────────────────────────────────────────────── 'health' => [ - 'label' => 'Health Check', - 'bash' => "curl -sf {$api}/../api-status.php | python3 -m json.tool", - 'cwd' => $root, - 'timeout' => 15, - 'continue_on_fail' => false, + 'label' => 'Health Check — API Status', + 'level' => 'infra', + 'bash' => "curl -sf {$api}/../api-status.php | python3 -m json.tool", + 'cwd' => $root, 'timeout' => 15, 'continue_on_fail' => false, ], + + // ── L1: AUTH & JWT ────────────────────────────────────────────────── + 'l1-auth' => [ + 'label' => 'L1 — Auth & JWT', + 'level' => 'l1', + 'bash' => implode(' && ', [ + "echo '━━━ L1.1 Login valido ━━━'", + "curl -sf -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@datacore.demo\",\"password\":\"Demo2026!\"}' | python3 -m json.tool || echo '[SKIP] utente non trovato'", + "echo ''", + "echo '━━━ L1.2 Login password errata (401) ━━━'", + "curl -sf -o /dev/null -w 'HTTP %{http_code}' -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@datacore.demo\",\"password\":\"WRONG\"}'", + "echo ''", + "echo '━━━ L1.3 /auth/me senza token (401) ━━━'", + "curl -sf -o /dev/null -w 'HTTP %{http_code}' {$api}/auth/me", + "echo ''", + "echo '━━━ L1.4 /auth/me con token valido ━━━'", + "TOKEN={$getToken} && [ -n \"\$TOKEN\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/auth/me | python3 -m json.tool || echo '[SKIP] token non disponibile'", + "echo ''", + "echo '━━━ L1.5 Rate limiting (5 login rapidi) ━━━'", + "for i in 1 2 3 4 5; do curl -sf -o /dev/null -w \"Tentativo \$i → HTTP %{http_code}\\n\" -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"ratelimit-test@test.com\",\"password\":\"wrong\"}'; done", + "echo '[OK] L1 Auth & JWT completato'", + ]), + 'cwd' => $root, 'timeout' => 45, 'continue_on_fail' => true, + ], + + // ── L2: MULTI-TENANT ──────────────────────────────────────────────── + 'l2-tenant' => [ + 'label' => 'L2 — Multi-Tenant Isolation', + 'level' => 'l2', + 'bash' => implode(' && ', [ + "echo '━━━ L2.1 Login utente DataCore ━━━'", + "TOKEN1={$getToken} && echo \"Token ottenuto: \${TOKEN1:0:20}...\"", + "echo ''", + "echo '━━━ L2.2 Organizations dell\'utente ━━━'", + "TOKEN={$getToken} && curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list | python3 -m json.tool", + "echo ''", + "echo '━━━ L2.3 Accesso org corrente ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/organizations/current | python3 -m json.tool || echo '[SKIP] nessuna org trovata'", + "echo ''", + "echo '━━━ L2.4 Dashboard overview con org ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/dashboard/overview | python3 -m json.tool || echo '[SKIP]'", + "echo '[OK] L2 Multi-Tenant completato'", + ]), + 'cwd' => $root, 'timeout' => 60, 'continue_on_fail' => true, + ], + + // ── L3: COMPLIANCE CORE ───────────────────────────────────────────── + 'l3-compliance' => [ + 'label' => 'L3 — Compliance Core', + 'level' => 'l3', + 'bash' => implode(' && ', [ + "echo '━━━ L3.1 Compliance score ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/dashboard/compliance-score | python3 -m json.tool || echo '[SKIP]'", + "echo ''", + "echo '━━━ L3.2 Risk matrix ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/risks/matrix | python3 -m json.tool || echo '[SKIP]'", + "echo ''", + "echo '━━━ L3.3 Audit controls (Art.21) ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/audit/controls | python3 -m json.tool || echo '[SKIP]'", + "echo ''", + "echo '━━━ L3.4 Incidents list ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/incidents/list | python3 -c \"import sys,json; d=json.load(sys.stdin); items=d.get('data',[]); print(f'Incidenti trovati: {len(items)}')\" || echo '[SKIP]'", + "echo ''", + "echo '━━━ L3.5 Upcoming deadlines ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/dashboard/upcoming-deadlines | python3 -m json.tool || echo '[SKIP]'", + "echo '[OK] L3 Compliance Core completato'", + ]), + 'cwd' => $root, 'timeout' => 90, 'continue_on_fail' => true, + ], + + // ── L4: B2B & SERVICES ────────────────────────────────────────────── + 'l4-b2b' => [ + 'label' => 'L4 — B2B & Services API', + 'level' => 'l4', + 'bash' => implode(' && ', [ + "echo '━━━ L4.1 Validate invite (endpoint esistenza) ━━━'", + "curl -sf -o /dev/null -w 'POST /auth/validate-invite → HTTP %{http_code}\\n' -X POST {$api}/auth/validate-invite -H 'Content-Type: application/json' -d '{\"invite_token\":\"invalid-test-token\"}'", + "echo ''", + "echo '━━━ L4.2 Supply chain risk overview ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/supply-chain/risk-overview | python3 -m json.tool || echo '[SKIP]'", + "echo ''", + "echo '━━━ L4.3 NCR stats ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/ncr/stats | python3 -m json.tool || echo '[SKIP]'", + "echo ''", + "echo '━━━ L4.4 Training compliance status ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/training/compliance-status | python3 -m json.tool || echo '[SKIP]'", + "echo '[OK] L4 B2B & Services completato'", + ]), + 'cwd' => $root, 'timeout' => 60, 'continue_on_fail' => true, + ], + + // ── L5: EXPORT & REPORTS ──────────────────────────────────────────── + 'l5-export' => [ + 'label' => 'L5 — Export & Reports', + 'level' => 'l5', + 'bash' => implode(' && ', [ + "echo '━━━ L5.1 Export CSV rischi ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -o /tmp/risks_export.csv -w 'CSV rischi: %{size_download} bytes (HTTP %{http_code})' -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" \"{$api}/audit/export?type=risks\" && head -3 /tmp/risks_export.csv || echo '[SKIP]'", + "echo ''", + "echo '━━━ L5.2 Export CSV incidenti ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -o /tmp/incidents_export.csv -w 'CSV incidenti: %{size_download} bytes (HTTP %{http_code})' -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" \"{$api}/audit/export?type=incidents\" || echo '[SKIP]'", + "echo ''", + "echo '━━━ L5.3 ISO 27001 mapping ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/audit/iso27001-mapping | python3 -c \"import sys,json; d=json.load(sys.stdin); items=d.get('data',[]); print(f'Controlli ISO 27001: {len(items)}')\" || echo '[SKIP]'", + "echo ''", + "echo '━━━ L5.4 Report audit log (ultimi 10) ━━━'", + "TOKEN={$getToken} && ORG_ID=\$(curl -sf -H \"Authorization: Bearer \$TOKEN\" {$api}/organizations/list 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); orgs=d.get('data',[]); print(orgs[0]['id'] if orgs else '')\" 2>/dev/null) && [ -n \"\$ORG_ID\" ] && curl -sf -H \"Authorization: Bearer \$TOKEN\" -H \"X-Organization-Id: \$ORG_ID\" {$api}/audit/logs | python3 -c \"import sys,json; d=json.load(sys.stdin); logs=d.get('data',[]); print(f'Log audit: {len(logs)} entries')\" || echo '[SKIP]'", + "echo '[OK] L5 Export & Reports completato'", + ]), + 'cwd' => $root, 'timeout' => 60, 'continue_on_fail' => true, + ], + + // ── SMOKE ─────────────────────────────────────────────────────────── 'smoke' => [ - 'label' => 'Smoke Tests (curl)', - 'bash' => implode(' && ', [ - "echo '=== Login demo ===' && curl -sf -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@datacore.demo\",\"password\":\"Demo2026!\"}' | python3 -m json.tool || echo '[SKIP] utente non ancora creato'", - "echo '=== /api/auth/me (no token) ===' && curl -sf {$api}/auth/me | python3 -m json.tool", - "echo '=== /api/dashboard/overview (no token → 401) ===' && curl -sf -w '\\nHTTP %{http_code}\\n' {$api}/dashboard/overview || true", + 'label' => 'Smoke Tests (curl rapido)', + 'level' => 'infra', + 'bash' => implode(' && ', [ + "echo '=== Login demo ===' && curl -sf -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@datacore.demo\",\"password\":\"Demo2026!\"}' | python3 -m json.tool || echo '[SKIP]'", + "echo '=== /auth/me senza token (401) ===' && curl -sf -o /dev/null -w 'HTTP %{http_code}' {$api}/auth/me", + "echo '=== /dashboard/overview (no token → 401) ===' && curl -sf -o /dev/null -w 'HTTP %{http_code}' {$api}/dashboard/overview", "echo '=== API status ===' && curl -sf {$api}/../api-status.php | python3 -m json.tool", ]), - 'cwd' => $root, - 'timeout' => 30, - 'continue_on_fail' => true, + 'cwd' => $root, 'timeout' => 30, 'continue_on_fail' => true, ], + + // ── SIMULAZIONI ───────────────────────────────────────────────────── 'sim01' => [ - 'label' => 'SIM-01 Onboarding + Assessment', - 'bash' => "NIS2_SIM=SIM01 {$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 180, - 'continue_on_fail' => false, + 'label' => 'SIM-01 Onboarding + Assessment', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM01 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 180, 'continue_on_fail' => false, ], 'sim02' => [ - 'label' => 'SIM-02 Ransomware Art.23', - 'bash' => "NIS2_SIM=SIM02 {$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 120, - 'continue_on_fail' => false, + 'label' => 'SIM-02 Ransomware Art.23', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM02 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 120, 'continue_on_fail' => false, ], 'sim03' => [ - 'label' => 'SIM-03 Data Breach Supply Chain', - 'bash' => "NIS2_SIM=SIM03 {$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 120, - 'continue_on_fail' => false, + 'label' => 'SIM-03 Data Breach Supply Chain', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM03 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 120, 'continue_on_fail' => false, ], 'sim04' => [ - 'label' => 'SIM-04 Whistleblowing SCADA', - 'bash' => "NIS2_SIM=SIM04 {$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 90, - 'continue_on_fail' => false, + 'label' => 'SIM-04 Whistleblowing SCADA', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM04 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 90, 'continue_on_fail' => false, ], 'sim05' => [ - 'label' => 'SIM-05 Audit Chain Verify', - 'bash' => "NIS2_SIM=SIM05 {$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 60, - 'continue_on_fail' => false, + 'label' => 'SIM-05 Audit Chain Verify', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM05 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 60, 'continue_on_fail' => false, + ], + 'sim06' => [ + 'label' => 'SIM-06 B2B License Provisioning', + 'level' => 'sim', + 'bash' => "NIS2_SIM=SIM06 {$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 90, 'continue_on_fail' => false, ], 'simulate' => [ - 'label' => 'Tutte le Simulazioni (SIM-01→05)', - 'bash' => "{$php} {$root}/simulate-nis2.php", - 'cwd' => $root, - 'timeout' => 600, - 'continue_on_fail' => false, + 'label' => 'Tutte le Simulazioni (SIM-01→06)', + 'level' => 'sim', + 'bash' => "{$php} {$root}/simulate-nis2.php", + 'cwd' => $root, 'timeout' => 720, 'continue_on_fail' => false, ], 'chain-verify' => [ - 'label' => 'Verifica Hash Chain Audit', - 'bash' => implode(' && ', [ + 'label' => 'Verifica Hash Chain Audit', + 'level' => 'infra', + 'bash' => implode(' && ', [ "echo '=== Verifica chain org demo ==='", "curl -sf -H 'Authorization: Bearer \$(cat /tmp/nis2_chain_token 2>/dev/null || echo \"\")' {$api}/audit/chain-verify | python3 -m json.tool || echo '[INFO] Autenticarsi prima con simulate'", ]), - 'cwd' => $root, - 'timeout' => 30, - 'continue_on_fail' => true, + 'cwd' => $root, 'timeout' => 30, 'continue_on_fail' => true, ], 'reset' => [ - 'label' => 'Reset Dati Demo', - 'bash' => "mysql -u nis2_agile_user -p\$(grep DB_PASSWORD {$root}/.env | cut -d= -f2) nis2_agile_db < {$root}/docs/sql/reset-demo.sql", - 'cwd' => $root, - 'timeout' => 30, - 'continue_on_fail' => false, + 'label' => 'Reset Dati Demo', + 'level' => 'infra', + 'bash' => "mysql -u nis2_agile_user -p\$(grep DB_PASSWORD {$root}/.env | cut -d= -f2) nis2_agile_db < {$root}/docs/sql/reset-demo.sql", + 'cwd' => $root, 'timeout' => 30, 'continue_on_fail' => false, + ], + 'full-suite' => [ + 'label' => 'Full Suite L1+L2+L3+L4+L5', + 'level' => 'infra', + 'bash' => implode(' && ', [ + "echo '════════════ L1 AUTH ════════════'", + "curl -sf -X POST {$api}/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"admin@datacore.demo\",\"password\":\"Demo2026!\"}' | python3 -c \"import sys,json; d=json.load(sys.stdin); print('Login:', 'OK' if d.get('success') else 'FAIL')\" || echo 'L1 SKIP'", + "echo '════════════ L2 TENANT ════════════'", + "curl -sf {$api}/../api-status.php | python3 -c \"import sys,json; d=json.load(sys.stdin); print('API:', d.get('status','?'))\"", + "echo '════════════ L5 EXPORT ════════════'", + "curl -sf {$api}/../api-status.php | python3 -m json.tool", + "echo '[OK] Full Suite completata'", + ]), + 'cwd' => $root, 'timeout' => 300, 'continue_on_fail' => true, ], 'full-reset-sim' => [ - 'label' => '⚡ Reset + Simula + Testa Tutto', - 'bash' => implode(' && ', [ + 'label' => 'Reset + Simula + Testa Tutto', + 'level' => 'infra', + 'bash' => implode(' && ', [ "echo '════════════════════════════════════════'", "echo ' FASE 1 — Reset database demo'", "echo '════════════════════════════════════════'", "mysql -u nis2_agile_user -p\$(grep DB_PASSWORD {$root}/.env | cut -d= -f2) nis2_agile_db < {$root}/docs/sql/reset-demo.sql", - "echo '[OK] Reset completato. Admin cristiano.benassati@gmail.com preservato.'", + "echo '[OK] Reset completato.'", "echo ''", "echo '════════════════════════════════════════'", - "echo ' FASE 2 — Simulazioni demo (SIM-01→05)'", + "echo ' FASE 2 — Simulazioni demo (SIM-01→06)'", "echo '════════════════════════════════════════'", PHP_BINARY . " {$root}/simulate-nis2.php", "echo ''", @@ -153,19 +293,7 @@ function getCommands(): array "curl -sf " . API_BASE . "/../api-status.php | python3 -m json.tool", "echo '[OK] Suite completa terminata.'", ]), - 'cwd' => $root, - 'timeout' => 720, - 'continue_on_fail' => false, - ], - 'all' => [ - 'label' => 'Full Suite (health + simulate + chain)', - 'bash' => implode(' && ', [ - "curl -sf " . API_BASE . "/../api-status.php | python3 -m json.tool", - PHP_BINARY . " {$root}/simulate-nis2.php", - ]), - 'cwd' => $root, - 'timeout' => 660, - 'continue_on_fail' => false, + 'cwd' => $root, 'timeout' => 900, 'continue_on_fail' => false, ], ]; } @@ -229,8 +357,8 @@ function runCommand(array $def, callable $emit): void stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); - $timeout = $def['timeout'] ?? 120; - $deadline = microtime(true) + $timeout; + $timeout = $def['timeout'] ?? 120; + $deadline = microtime(true) + $timeout; $buf1 = ''; $buf2 = ''; @@ -245,7 +373,6 @@ function runCommand(array $def, callable $emit): void $w = null; $e = null; $changed = @stream_select($r, $w, $e, 0, 50000); - if ($changed === false) break; foreach ($r as $s) { @@ -270,7 +397,6 @@ function runCommand(array $def, callable $emit): void if (feof($pipes[1]) && feof($pipes[2])) break; if (connection_aborted()) { proc_terminate($proc, 9); break; } - // flush heartbeat if (ob_get_level()) ob_flush(); flush(); } @@ -288,9 +414,7 @@ function runCommand(array $def, callable $emit): void function sseWrite(string $event, string $data): void { - // JSON-encode non-JSON data for transport safety - $out = "event: {$event}\ndata: " . $data . "\n\n"; - echo $out; + echo "event: {$event}\ndata: " . $data . "\n\n"; if (ob_get_level()) ob_flush(); flush(); } @@ -301,8 +425,8 @@ function serveStatus(): void { header('Content-Type: application/json'); - $api = API_BASE; - $up = false; + $api = API_BASE; + $up = false; $info = []; $ch = curl_init("{$api}/../api-status.php"); @@ -321,14 +445,76 @@ function serveStatus(): void } echo json_encode([ - 'api_up' => $up, - 'api_url' => $api, - 'api_info' => $info, - 'php_version'=> PHP_VERSION, - 'ts' => date('c'), + 'api_up' => $up, + 'api_url' => $api, + 'api_info' => $info, + 'php_version' => PHP_VERSION, + 'ts' => date('c'), ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } +// ── DB Stats JSON ───────────────────────────────────────────────────────────── + +function serveDbStats(): void +{ + header('Content-Type: application/json'); + + $envFile = PROJECT_ROOT . '/.env'; + if (!file_exists($envFile)) { + echo json_encode(['error' => '.env non trovato'], JSON_PRETTY_PRINT); + return; + } + + $env = []; + foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; + [$k, $v] = explode('=', $line, 2); + $env[trim($k)] = trim($v); + } + + try { + $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', + $env['DB_HOST'] ?? '127.0.0.1', + $env['DB_NAME'] ?? 'nis2_agile_db' + ); + $pdo = new PDO($dsn, $env['DB_USER'] ?? '', $env['DB_PASSWORD'] ?? '', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 3, + ]); + + $tables = [ + 'organizations', 'users', 'user_organizations', + 'assessments', 'assessment_responses', + 'risks', 'risk_treatments', + 'incidents', 'incident_timeline', + 'policies', 'suppliers', + 'training_courses', 'training_assignments', + 'assets', 'compliance_controls', 'evidence_files', + 'audit_logs', 'ai_interactions', 'email_log', + 'non_conformities', 'corrective_actions', + ]; + + $stats = []; + foreach ($tables as $t) { + try { + $stmt = $pdo->query("SELECT COUNT(*) FROM `{$t}`"); + $stats[$t] = (int)$stmt->fetchColumn(); + } catch (\Throwable) { + $stats[$t] = null; // tabella non esiste + } + } + + echo json_encode([ + 'stats' => $stats, + 'total' => array_sum(array_filter($stats, fn($v) => $v !== null)), + 'ts' => date('c'), + ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + } catch (\Throwable $e) { + echo json_encode(['error' => $e->getMessage()], JSON_PRETTY_PRINT); + } +} + // ── UI ──────────────────────────────────────────────────────────────────────── function serveUI(): void @@ -337,6 +523,7 @@ function serveUI(): void $cmdsJson = json_encode(array_map(fn($k, $v) => [ 'id' => $k, 'label' => $v['label'], + 'level' => $v['level'] ?? 'infra', ], array_keys($commands), $commands), JSON_UNESCAPED_UNICODE); $token = ACCESS_TOKEN; @@ -344,23 +531,26 @@ function serveUI(): void $url = "https://nis2.agile.software/test-runner.php?t={$token}"; $demoCredentials = [ - ['role' => '★ Super Admin (permanente)', 'email' => 'cristiano.benassati@gmail.com', 'password' => 'Silvia1978!@', 'org' => 'Tutte'], - ['role' => 'Admin (DataCore IT)', 'email' => 'admin@datacore.demo', 'password' => 'Demo2026!', 'org' => 'DataCore S.r.l.'], - ['role' => 'Compliance (MedClinic)', 'email' => 'compliance@medclinic.demo', 'password' => 'Demo2026!', 'org' => 'MedClinic Italia S.p.A.'], - ['role' => 'CISO (EnerNet)', 'email' => 'ciso@enernet.demo', 'password' => 'Demo2026!', 'org' => 'EnerNet Distribuzione S.r.l.'], + ['role' => '★ Super Admin', 'email' => 'cristiano.benassati@gmail.com', 'password' => 'Silvia1978!@', 'org' => 'Tutte'], + ['role' => 'Admin (DataCore)', 'email' => 'admin@datacore.demo', 'password' => 'Demo2026!', 'org' => 'DataCore S.r.l.'], + ['role' => 'Compliance (MedClinic)', 'email' => 'compliance@medclinic.demo', 'password' => 'Demo2026!', 'org' => 'MedClinic Italia'], + ['role' => 'CISO (EnerNet)', 'email' => 'ciso@enernet.demo', 'password' => 'Demo2026!', 'org' => 'EnerNet S.r.l.'], + ['role' => 'Consultant', 'email' => 'consultant@nis2agile.demo', 'password' => 'Demo2026!', 'org' => 'Multi-azienda'], ]; $credsRows = ''; foreach ($demoCredentials as $c) { $credsRows .= "{$c['role']}{$c['email']}{$c['password']}{$c['org']}"; } + // SIM cards $simCards = ''; $simDefs = [ - 'sim01' => ['SIM-01', 'Onboarding + Assessment', '3 aziende, gap analysis 80 domande, score iniziale', 'cyan'], - 'sim02' => ['SIM-02', 'Ransomware Art.23', 'Incidente critico, timeline 24h/72h, CSIRT notifica', 'orange'], - 'sim03' => ['SIM-03', 'Data Breach Supply Chain','Fornitore compromesso, Art.23 + GDPR Art.33 parallelo', 'red'], - 'sim04' => ['SIM-04', 'Whistleblowing SCADA', 'Segnalazione anonima, assegnazione, chiusura tracciata', 'purple'], - 'sim05' => ['SIM-05', 'Audit Chain Verify', 'Verifica integrità SHA-256, export certificato', 'green'], + 'sim01' => ['SIM-01', 'Onboarding + Assessment', '3 aziende, gap analysis 80 domande', 'cyan'], + 'sim02' => ['SIM-02', 'Ransomware Art.23', 'Incidente critico, 24h/72h/30d timeline', 'orange'], + 'sim03' => ['SIM-03', 'Data Breach Supply Chain', 'Fornitore compromesso, Art.23 parallelo', 'red'], + 'sim04' => ['SIM-04', 'Whistleblowing SCADA', 'Segnalazione anonima tracciata a chiusura', 'purple'], + 'sim05' => ['SIM-05', 'Audit Chain Verify', 'Verifica integrità SHA-256 audit trail', 'green'], + 'sim06' => ['SIM-06', 'B2B License Provisioning', 'Invite token → org creata + SSO + JWT', 'yellow'], ]; foreach ($simDefs as $id => [$code, $name, $desc, $col]) { $simCards .= << HTML; } + $simCards .= '
'; + $simCards .= ""; - $testBtns = "\n
\n"; - $testCmds = ['health', 'smoke', 'simulate', 'chain-verify', 'reset', 'all']; - foreach ($testCmds as $id) { - $label = $commands[$id]['label'] ?? $id; - $extra = in_array($id, ['reset', 'all']) ? ' btn-danger' : ''; - $testBtns .= "\n"; + // Level buttons + $levelBtns = "\n
\n"; + $levelDefs = [ + 'health' => ['L0', 'Health Check', 'infra'], + 'smoke' => ['L0', 'Smoke Tests', 'infra'], + 'l1-auth' => ['L1', 'Auth & JWT', 'l1'], + 'l2-tenant' => ['L2', 'Multi-Tenant', 'l2'], + 'l3-compliance' => ['L3', 'Compliance Core', 'l3'], + 'l4-b2b' => ['L4', 'B2B & Services', 'l4'], + 'l5-export' => ['L5', 'Export & Reports', 'l5'], + 'chain-verify' => ['—', 'Hash Chain Verify', 'infra'], + 'full-suite' => ['ALL', 'Full Suite L1→L5', 'infra'], + 'reset' => ['⚠', 'Reset Dati Demo', 'danger'], + ]; + foreach ($levelDefs as $id => [$badge, $label, $cls]) { + $danger = $cls === 'danger' ? ' btn-danger' : ''; + $levelBtns .= "\n"; + } + + // Coverage endpoint map + $coverageEndpoints = [ + ['Auth', 'POST /api/auth/login', 'L1', 'ok'], + ['Auth', 'POST /api/auth/register', 'L1', 'ok'], + ['Auth', 'GET /api/auth/me', 'L1', 'ok'], + ['Auth', 'POST /api/auth/refresh', 'L1', 'partial'], + ['Auth', 'POST /api/auth/validate-invite', 'L4', 'ok'], + ['Org', 'GET /api/organizations/list', 'L2', 'ok'], + ['Org', 'GET /api/organizations/current', 'L2', 'ok'], + ['Org', 'POST /api/organizations/create', 'L2', 'sim'], + ['Dashboard','GET /api/dashboard/overview', 'L2', 'ok'], + ['Dashboard','GET /api/dashboard/compliance-score', 'L3', 'ok'], + ['Dashboard','GET /api/dashboard/upcoming-deadlines', 'L3', 'ok'], + ['Dashboard','GET /api/dashboard/risk-heatmap', 'L3', 'partial'], + ['Risk', 'GET /api/risks/list', 'L3', 'ok'], + ['Risk', 'GET /api/risks/matrix', 'L3', 'ok'], + ['Risk', 'POST /api/risks/create', 'sim','ok'], + ['Incident', 'GET /api/incidents/list', 'L3', 'ok'], + ['Incident', 'POST /api/incidents/create', 'sim','ok'], + ['Policy', 'GET /api/policies/list', 'L3', 'partial'], + ['Audit', 'GET /api/audit/controls', 'L3', 'ok'], + ['Audit', 'GET /api/audit/export', 'L5', 'ok'], + ['Audit', 'GET /api/audit/iso27001-mapping','L5', 'ok'], + ['Audit', 'GET /api/audit/logs', 'L5', 'ok'], + ['Supply', 'GET /api/supply-chain/risk-overview', 'L4', 'ok'], + ['Training', 'GET /api/training/compliance-status', 'L4', 'ok'], + ['NCR', 'GET /api/ncr/stats', 'L4', 'ok'], + ['Onboard', 'POST /api/onboarding/complete', 'sim','ok'], + ['Admin', 'GET /api/admin/stats', 'admin','skip'], + ]; + $covRows = ''; + foreach ($coverageEndpoints as [$module, $ep, $level, $status]) { + $statusColor = match($status) { + 'ok' => 'var(--green)', + 'partial' => 'var(--yellow)', + 'sim' => 'var(--cyan)', + 'skip' => 'var(--muted)', + default => 'var(--red)', + }; + $statusLabel = match($status) { + 'ok' => '✓ Testato', + 'partial' => '~ Parziale', + 'sim' => '⚙ Solo SIM', + 'skip' => '— Skip', + default => '✗ Mancante', + }; + $covRows .= "{$module}{$ep}" + . "{$level}" + . "{$statusLabel}"; } echo << -NIS2 Agile — Test Runner +NIS2 Agile — Test Runner v2 - @@ -600,8 +943,8 @@ body { display: flex; height: 100vh; background: var(--navy); color: var(--text)
- NIS2 Agile Test Runner — pronto. - ──────────────────────────────────────────────────────── + NIS2 Agile Test Runner v2 — pronto. + ────────────────────────────────────────────────────────────────────
@@ -610,13 +953,48 @@ const COMMANDS = {$cmdsJson}; let es = null; let timerInterval = null; let t0 = null; +let currentRun = null; + +const HISTORY_KEY = 'nis2_run_history'; +const MAX_HISTORY = 15; + +// ── History ────────────────────────────────────────────────────────────────── +function loadHistory() { + try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } +} +function saveHistory(runs) { + localStorage.setItem(HISTORY_KEY, JSON.stringify(runs.slice(0, MAX_HISTORY))); +} +function addHistory(entry) { + const runs = loadHistory(); + runs.unshift(entry); + saveHistory(runs); + renderHistory(); +} +function renderHistory() { + const list = document.getElementById('history-list'); + const runs = loadHistory(); + if (!runs.length) { list.innerHTML = '

Nessun run ancora

'; return; } + list.innerHTML = runs.map(r => { + const icon = r.status === 'ok' ? '✓' : r.status === 'err' ? '✗' : '~'; + const cls = r.status === 'ok' ? 'h-ok' : r.status === 'err' ? 'h-err' : ''; + const time = new Date(r.ts).toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'}); + const elapsed = r.elapsed ? ` (\${r.elapsed}s)` : ''; + return `
+ \${icon} + \${r.label} + \${time}\${elapsed} +
`; + }).join(''); +} +renderHistory(); // ── API status check ───────────────────────────────────────────────────────── async function checkApiStatus() { try { const r = await fetch('status', { signal: AbortSignal.timeout(6000) }); const d = await r.json(); - const el = document.getElementById('api-status'); + const el = document.getElementById('api-status'); const lbl = document.getElementById('api-label'); if (d.api_up) { el.className = 'badge-status up'; @@ -632,15 +1010,35 @@ async function checkApiStatus() { checkApiStatus(); setInterval(checkApiStatus, 30000); +// ── DB Stats ───────────────────────────────────────────────────────────────── +async function loadDbStats() { + const grid = document.getElementById('stats-grid'); + const totalEl = document.getElementById('stats-total'); + grid.innerHTML = '
Caricamento...
'; + try { + const r = await fetch('db-stats', { signal: AbortSignal.timeout(8000) }); + const d = await r.json(); + if (d.error) { grid.innerHTML = `
\${d.error}
`; return; } + const stats = d.stats || {}; + const entries = Object.entries(stats).filter(([,v]) => v !== null); + grid.innerHTML = entries.map(([t, v]) => + `
+
\${v.toLocaleString('it-IT')}
+
\${t}
+
` + ).join(''); + totalEl.textContent = `Totale righe DB: \${(d.total || 0).toLocaleString('it-IT')}`; + } catch (e) { + grid.innerHTML = `
Errore: \${e.message}
`; + } +} + // ── Tab switch ─────────────────────────────────────────────────────────────── function showTab(id) { - document.querySelectorAll('.tab').forEach((t, i) => { - const ids = ['test', 'sim', 'cred']; - t.classList.toggle('active', ids[i] === id); - }); - document.querySelectorAll('.tab-panel').forEach(p => { - p.classList.toggle('active', p.id === 'tab-' + id); - }); + const tabIds = ['test', 'sim', 'cov', 'stats', 'cred']; + document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', tabIds[i] === id)); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + id)); + if (id === 'stats') loadDbStats(); } // ── Terminal ───────────────────────────────────────────────────────────────── @@ -665,14 +1063,16 @@ function runCmd(id) { const cmd = COMMANDS.find(c => c.id === id); const label = cmd ? cmd.label : id; + currentRun = { id, label, ts: new Date().toISOString(), status: 'running', elapsed: null }; + document.getElementById('term-title').textContent = '▶ ' + label; document.getElementById('running-bar').classList.add('active'); document.getElementById('btn-stop').classList.add('visible'); document.querySelectorAll('.btn').forEach(b => b.disabled = true); - appendLine('sep', '────────────────────────────────────────────────────────'); - appendLine('ts', new Date().toLocaleTimeString('it-IT') + ' Avvio: ' + label); - appendLine('sep', '────────────────────────────────────────────────────────'); + appendLine('sep', '────────────────────────────────────────────────────────────────────'); + appendLine('ts', new Date().toLocaleTimeString('it-IT') + ' ▶ ' + label); + appendLine('sep', '────────────────────────────────────────────────────────────────────'); t0 = Date.now(); timerInterval = setInterval(() => { @@ -682,26 +1082,41 @@ function runCmd(id) { es = new EventSource('events?cmd=' + encodeURIComponent(id)); - es.addEventListener('stdout', e => appendLine('out', e.data)); + es.addEventListener('stdout', e => { + const d = e.data; + if (d.includes('[OK]') || d.startsWith('✓')) appendLine('ok', d); + else if (d.includes('[WARN]')) appendLine('warn', d); + else if (d.startsWith('━━━') || d.startsWith('════')) appendLine('info', d); + else appendLine('out', d); + }); es.addEventListener('stderr', e => { const d = e.data; - if (d.includes('[OK]') || d.includes('✓')) appendLine('ok', d); - else if (d.includes('[WARN]')) appendLine('warn', d); - else appendLine('err', d); + if (d.includes('[OK]') || d.startsWith('✓')) appendLine('ok', d); + else if (d.includes('[WARN]') || d.includes('[INFO]')) appendLine('warn', d); + else if (d.includes('[SKIP]')) appendLine('warn', d); + else appendLine('err', d); }); es.addEventListener('done', e => { + let elapsed = null; try { const d = JSON.parse(e.data); - appendLine('ok', '✓ Completato in ' + d.elapsed + 's'); + elapsed = d.elapsed; + appendLine('ok', '✓ Completato in ' + d.elapsed + 's'); } catch { appendLine('ok', '✓ Completato'); } + if (currentRun) { currentRun.status = 'ok'; currentRun.elapsed = elapsed; addHistory(currentRun); } finish(); }); - es.onerror = () => { appendLine('err', '[ERRORE] Connessione SSE interrotta'); finish(); }; + es.onerror = () => { + appendLine('err', '[ERRORE] Connessione SSE interrotta'); + if (currentRun) { currentRun.status = 'err'; addHistory(currentRun); } + finish(); + }; } function stopCmd() { if (es) { es.close(); es = null; } appendLine('warn', '[STOP] Interrotto dall\'utente'); + if (currentRun) { currentRun.status = 'err'; addHistory(currentRun); } finish(); } @@ -712,6 +1127,7 @@ function finish() { document.getElementById('btn-stop').classList.remove('visible'); document.getElementById('term-title').textContent = 'Completato · ' + new Date().toLocaleTimeString('it-IT'); document.querySelectorAll('.btn').forEach(b => b.disabled = false); + currentRun = null; }