diff --git a/test-runner.php b/test-runner.php
new file mode 100644
index 0000000..3d3577c
--- /dev/null
+++ b/test-runner.php
@@ -0,0 +1,687 @@
+
'
+ . '401 — Token richiesto
Accedi con ?t=Nis2Test2026
';
+ exit;
+}
+
+// ── Router ────────────────────────────────────────────────────────────────────
+
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
+$slug = ltrim(substr($path, strlen($base)), '/');
+
+if ($slug === 'events') {
+ serveEvents();
+ exit;
+}
+if ($slug === 'status') {
+ serveStatus();
+ exit;
+}
+
+serveUI();
+exit;
+
+// ── Commands ──────────────────────────────────────────────────────────────────
+
+function getCommands(): array
+{
+ $php = PHP_BINARY ?: 'php';
+ $root = PROJECT_ROOT;
+ $api = API_BASE;
+
+ return [
+ 'health' => [
+ 'label' => 'Health Check',
+ 'bash' => "curl -sf {$api}/../api-status.php | python3 -m json.tool",
+ 'cwd' => $root,
+ 'timeout' => 15,
+ 'continue_on_fail' => false,
+ ],
+ '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",
+ "echo '=== API status ===' && curl -sf {$api}/../api-status.php | python3 -m json.tool",
+ ]),
+ 'cwd' => $root,
+ 'timeout' => 30,
+ 'continue_on_fail' => true,
+ ],
+ 'sim01' => [
+ 'label' => 'SIM-01 Onboarding + Assessment',
+ '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,
+ ],
+ '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,
+ ],
+ 'sim04' => [
+ 'label' => 'SIM-04 Whistleblowing SCADA',
+ '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,
+ ],
+ 'simulate' => [
+ 'label' => 'Tutte le Simulazioni (SIM-01→05)',
+ 'bash' => "{$php} {$root}/simulate-nis2.php",
+ 'cwd' => $root,
+ 'timeout' => 600,
+ 'continue_on_fail' => false,
+ ],
+ 'chain-verify' => [
+ 'label' => 'Verifica Hash Chain Audit',
+ '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,
+ ],
+ '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,
+ ],
+ '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,
+ ],
+ ];
+}
+
+// ── SSE Events handler ────────────────────────────────────────────────────────
+
+function serveEvents(): void
+{
+ $cmd = $_GET['cmd'] ?? '';
+ $commands = getCommands();
+
+ if (!isset($commands[$cmd])) {
+ http_response_code(400);
+ echo "Comando sconosciuto: {$cmd}";
+ return;
+ }
+
+ $def = $commands[$cmd];
+
+ header('Content-Type: text/event-stream');
+ header('Cache-Control: no-cache');
+ header('X-Accel-Buffering: no');
+
+ if (ob_get_level()) ob_end_clean();
+
+ sseWrite('start', json_encode(['cmd' => $cmd, 'label' => $def['label'], 'ts' => date('c')]));
+
+ $t0 = microtime(true);
+ runCommand($def, function (string $type, string $line) {
+ sseWrite($type, $line);
+ });
+
+ $elapsed = round(microtime(true) - $t0, 2);
+ sseWrite('done', json_encode(['cmd' => $cmd, 'elapsed' => $elapsed]));
+}
+
+function runCommand(array $def, callable $emit): void
+{
+ $descriptors = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $env = array_merge($_ENV, getenv() ?: [], ['NIS2_API_BASE' => API_BASE]);
+
+ $proc = proc_open(
+ $def['bash'],
+ $descriptors,
+ $pipes,
+ $def['cwd'] ?? PROJECT_ROOT,
+ $env
+ );
+
+ if (!is_resource($proc)) {
+ $emit('stderr', '[ERRORE] Impossibile avviare il processo');
+ return;
+ }
+
+ fclose($pipes[0]);
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ $timeout = $def['timeout'] ?? 120;
+ $deadline = microtime(true) + $timeout;
+ $buf1 = '';
+ $buf2 = '';
+
+ while (true) {
+ if (microtime(true) > $deadline) {
+ $emit('stderr', "[TIMEOUT] Processo terminato dopo {$timeout}s");
+ proc_terminate($proc, 9);
+ break;
+ }
+
+ $r = [$pipes[1], $pipes[2]];
+ $w = null; $e = null;
+
+ $changed = @stream_select($r, $w, $e, 0, 50000);
+
+ if ($changed === false) break;
+
+ foreach ($r as $s) {
+ $chunk = fread($s, 4096);
+ if ($chunk === false || $chunk === '') continue;
+
+ if ($s === $pipes[1]) {
+ $buf1 .= $chunk;
+ while (($nl = strpos($buf1, "\n")) !== false) {
+ $emit('stdout', rtrim(substr($buf1, 0, $nl)));
+ $buf1 = substr($buf1, $nl + 1);
+ }
+ } else {
+ $buf2 .= $chunk;
+ while (($nl = strpos($buf2, "\n")) !== false) {
+ $emit('stderr', rtrim(substr($buf2, 0, $nl)));
+ $buf2 = substr($buf2, $nl + 1);
+ }
+ }
+ }
+
+ 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();
+ }
+
+ if ($buf1 !== '') $emit('stdout', rtrim($buf1));
+ if ($buf2 !== '') $emit('stderr', rtrim($buf2));
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($proc);
+
+ if (ob_get_level()) ob_flush();
+ flush();
+}
+
+function sseWrite(string $event, string $data): void
+{
+ // JSON-encode non-JSON data for transport safety
+ $out = "event: {$event}\ndata: " . $data . "\n\n";
+ echo $out;
+ if (ob_get_level()) ob_flush();
+ flush();
+}
+
+// ── Status JSON ───────────────────────────────────────────────────────────────
+
+function serveStatus(): void
+{
+ header('Content-Type: application/json');
+
+ $api = API_BASE;
+ $up = false;
+ $info = [];
+
+ $ch = curl_init("{$api}/../api-status.php");
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_SSL_VERIFYPEER => false,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code === 200 && $resp) {
+ $up = true;
+ $info = json_decode($resp, true) ?: [];
+ }
+
+ echo json_encode([
+ 'api_up' => $up,
+ 'api_url' => $api,
+ 'api_info' => $info,
+ 'php_version'=> PHP_VERSION,
+ 'ts' => date('c'),
+ ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+}
+
+// ── UI ────────────────────────────────────────────────────────────────────────
+
+function serveUI(): void
+{
+ $commands = getCommands();
+ $cmdsJson = json_encode(array_map(fn($k, $v) => [
+ 'id' => $k,
+ 'label' => $v['label'],
+ ], array_keys($commands), $commands), JSON_UNESCAPED_UNICODE);
+
+ $token = ACCESS_TOKEN;
+ $api = API_BASE;
+ $url = "https://nis2.agile.software/test-runner.php?t={$token}";
+
+ $demoCredentials = [
+ ['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.'],
+ ];
+ $credsRows = '';
+ foreach ($demoCredentials as $c) {
+ $credsRows .= "| {$c['role']} | {$c['email']} | {$c['password']} | {$c['org']} |
";
+ }
+
+ $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'],
+ ];
+ foreach ($simDefs as $id => [$code, $name, $desc, $col]) {
+ $simCards .= <<
+ {$code}
+
+ {$name}
+ {$desc}
+
+
+ HTML;
+ }
+
+ $testBtns = '';
+ $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";
+ }
+
+ echo <<
+
+
+
+
+NIS2 Agile — Test Runner
+
+
+
+
+
+
+
+
+
+
+
+ NIS2 Agile Test Runner — pronto.
+ ────────────────────────────────────────────────────────
+
+
+
+
+
+
+HTML;
+}