setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec(file_get_contents(APP_ROOT . '/storage/schema.sql')); $db = new Database($tmpDb); AdminAuth::setDatabase($db); $db->setSetting('admin_password_hash', password_hash('irrelevant-password-123', PASSWORD_BCRYPT)); // Session must be active before seeding $_SESSION, or session_start() inside // AdminAuth::startSession() would overwrite it from (empty) session storage. // Buffer output so session-setup calls never warn about sent headers after a // session was destroyed and restarted mid-test. ini_set('session.save_path', sys_get_temp_dir()); ini_set('session.use_cookies', '0'); ob_start(); session_start(); // ── 1. An unauthenticated session is rejected on the first request ──────────── $_SESSION = []; check('empty session is NOT authenticated', AdminAuth::isAuthenticated() === false); // ── 2. An active session is authenticated and refreshes last activity ──────── $_SESSION = [ 'admin_authenticated' => true, 'admin_login_at' => time() - 60, 'admin_last_activity' => time() - 60, ]; $before = $_SESSION['admin_last_activity']; check('active session is authenticated', AdminAuth::isAuthenticated() === true); check('activity timestamp refreshed', $_SESSION['admin_last_activity'] > $before); // ── 2b. A session idle within the 4 h window is still accepted ─────────────── // Two hours away from an open form (e.g. working in another app) no longer // logs the admin out mid-marathon. $_SESSION = [ 'admin_authenticated' => true, 'admin_login_at' => time() - 7200, 'admin_last_activity' => time() - 7200, // 2 h idle < 4 h idle timeout ]; check('session idle 2 h is still authenticated', AdminAuth::isAuthenticated() === true); // ── 2c. Session ID rotation fires on the interval, not on an exact modulo ──── // Seed a rotation older than the 30 min interval; a request at an arbitrary // time must still rotate (the old modulo-on-age check never fired). $_SESSION = [ 'admin_authenticated' => true, 'admin_login_at' => time() - 60, 'admin_last_activity' => time() - 60, 'admin_last_rotation' => time() - 3600, // 1 h since last rotation ]; $sidBefore = session_id(); AdminAuth::isAuthenticated(); check('stale rotation triggers session_regenerate_id', session_id() !== $sidBefore); check('rotation timestamp updated', ($_SESSION['admin_last_rotation'] ?? 0) > time() - 60); // ── 3. An idle session is rejected and destroyed (no redirect/exit) ────────── // Last: enforceSessionTimeout() destroys the session, so no further auth call // restarts it (which would warn under CLI header constraints). $_SESSION = [ 'admin_authenticated' => true, 'admin_login_at' => time() - 21600, 'admin_last_activity' => time() - 21600, // 6 h idle > 4 h idle timeout ]; check('idle session is NOT authenticated', AdminAuth::isAuthenticated() === false); check('idle session was destroyed', empty($_SESSION['admin_authenticated'])); // ── Cleanup ───────────────────────────────────────────────────────────────── $db->setSetting('admin_password_hash', ''); unlink($tmpDb); ob_end_flush(); echo "\n"; if ($failures === 0) { echo "✅ Session-keepalive smoke test passed.\n"; exit(0); } echo "❌ {$failures} check(s) failed.\n"; exit(1);