markTestSkipped('xdebug required to inspect headers'); } // Reset any session left over from another test. if (session_status() === PHP_SESSION_ACTIVE) { $_SESSION = []; session_destroy(); } if (!headers_sent()) { header_remove(); } } protected function tearDown(): void { if (session_status() === PHP_SESSION_ACTIVE) { $_SESSION = []; session_destroy(); } } /** Every Set-Cookie emitted must carry Secure+HttpOnly+SameSite. */ private function assertHardenedSetCookie(string $header): void { $this->assertStringContainsStringIgnoringCase('HttpOnly', $header, $header); $this->assertStringContainsStringIgnoringCase('Secure', $header, $header); $this->assertStringContainsStringIgnoringCase('SameSite', $header, $header); } /** Start an admin session exactly the way the panel does. */ private function startAdminSession(): void { $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; // Simulate production: TLS terminated by a trusted proxy, so // $_SERVER['HTTPS'] is absent and only X-Forwarded-Proto is set. unset($_SERVER['HTTPS']); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; // AdminAuth::startSession() is private; logout() calls it first. // Seed the session directly with the same params it applies. session_set_cookie_params([ 'lifetime' => 604800, 'path' => '/admin', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict', ]); session_start(); $_SESSION['admin_authenticated'] = true; } public function testLogoutEmitsHardenedDeletionCookie(): void { $this->startAdminSession(); header_remove(); AdminAuth::logout(); $headers = xdebug_get_headers(); $cookies = array_values(array_filter( $headers, static fn (string $h): bool => stripos($h, 'Set-Cookie:') === 0 && stripos($h, 'PHPSESSID') !== false )); $this->assertNotEmpty($cookies, 'logout must clear the session cookie'); foreach ($cookies as $header) { $this->assertHardenedSetCookie($header); // Deletion requires an expiry in the past. $this->assertMatchesRegularExpression('/expires=.*(1981|1970)/i', $header, $header); } } public function testLogoutIsNoOpWhenNoSession(): void { // No active session — logout() must return quietly, not fatal. $this->assertSame(PHP_SESSION_NONE, session_status()); AdminAuth::logout(); $this->assertSame(PHP_SESSION_NONE, session_status()); } /** * The Secure flag must be driven by RequestUtil (proxy-aware), not by the * raw $_SERVER['HTTPS'] idiom that regressed in production. */ public function testSecureDetectionUnderProxyTermination(): void { $this->assertTrue(RequestUtil::isSecure([ 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'https', ])); $this->assertFalse(RequestUtil::isSecure([ 'REMOTE_ADDR' => '203.0.113.7', 'HTTP_X_FORWARDED_PROTO' => 'https', ])); } }