Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security;
use App\Framework\Auth\Auth;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Middlewares\AuthMiddleware;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Session\SessionInterface;
use PHPUnit\Framework\TestCase;
/**
* Critical Security Tests for Authentication
* Tests gegen OWASP A07:2021 Identification and Authentication Failures
*/
final class AuthenticationSecurityTest extends TestCase
{
private SessionInterface $session;
private Auth $auth;
private AuthMiddleware $middleware;
private RequestStateManager $stateManager;
protected function setUp(): void
{
$this->session = $this->createMock(SessionInterface::class);
$this->auth = $this->createMock(Auth::class);
$this->middleware = new AuthMiddleware($this->auth);
$this->stateManager = $this->createMock(RequestStateManager::class);
}
/**
* Test: Nicht-authentifizierte Benutzer werden blockiert
* OWASP A07:2021 Identification and Authentication Failures
*/
public function test_blocks_unauthenticated_access(): void
{
// Arrange: Benutzer nicht authentifiziert
$this->auth->method('isAuthenticated')->willReturn(false);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
$next->expects($this->never())->method('__invoke');
// Act & Assert: Exception erwartet
$this->expectException(\App\Framework\Http\Exception\HttpException::class);
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Authentifizierte Benutzer werden durchgelassen
*/
public function test_allows_authenticated_access(): void
{
// Arrange: Benutzer authentifiziert
$this->auth->method('isAuthenticated')->willReturn(true);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
$next->expects($this->once())->method('__invoke')->willReturn($context);
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$this->assertSame($context, $result);
}
/**
* Test: Session Hijacking Schutz
* Validiert Session-Fingerprinting
*/
public function test_session_fingerprint_validation(): void
{
// Arrange: Authentifiziert aber Session-Fingerprint geändert
$this->auth->method('isAuthenticated')->willReturn(true);
$this->auth->method('validateSessionFingerprint')->willReturn(false);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Session Hijacking erkannt
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Session fingerprint validation failed');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Concurrent Login Detection
* Erkennt verdächtige gleichzeitige Logins
*/
public function test_concurrent_login_detection(): void
{
// Arrange: Benutzer von verschiedenen IPs eingeloggt
$this->auth->method('isAuthenticated')->willReturn(true);
$this->auth->method('validateSessionFingerprint')->willReturn(true);
$this->auth->method('detectConcurrentSessions')->willReturn(true);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Concurrent Session erkannt
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Concurrent session detected');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Session Timeout Validierung
*/
public function test_session_timeout_validation(): void
{
// Arrange: Session abgelaufen
$this->auth->method('isAuthenticated')->willReturn(true);
$this->auth->method('isSessionExpired')->willReturn(true);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Session Timeout
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Session expired');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Rate Limiting für Authentication Failures
* Verhindert Brute Force Attacks
*/
public function test_authentication_rate_limiting(): void
{
// Arrange: Zu viele fehlgeschlagene Login-Versuche
$this->auth->method('isAuthenticated')->willReturn(false);
$this->auth->method('isRateLimited')->willReturn(true);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Rate Limit erreicht
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Authentication rate limit exceeded');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: IP Whitelist Validation
* Admin-Bereiche nur von bestimmten IPs
*/
public function test_ip_whitelist_validation(): void
{
// Arrange: IP nicht in Whitelist
$this->auth->method('isAuthenticated')->willReturn(true);
$this->auth->method('isIpWhitelisted')->willReturn(false);
$request = $this->createRequestWithIp('192.168.1.100');
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: IP nicht erlaubt
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Access from this IP address is not allowed');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Account Lockout nach fehlgeschlagenen Versuchen
*/
public function test_account_lockout_after_failed_attempts(): void
{
// Arrange: Account gesperrt nach zu vielen Versuchen
$this->auth->method('isAuthenticated')->willReturn(false);
$this->auth->method('isAccountLocked')->willReturn(true);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Account gesperrt
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Account is locked due to too many failed attempts');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Two-Factor Authentication Validation
*/
public function test_two_factor_authentication_required(): void
{
// Arrange: 2FA erforderlich aber nicht bereitgestellt
$this->auth->method('isAuthenticated')->willReturn(true);
$this->auth->method('requires2FA')->willReturn(true);
$this->auth->method('is2FAValid')->willReturn(false);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: 2FA erforderlich
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Two-factor authentication required');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
// Helper Methods
private function createRequestWithIp(string $ip): Request
{
$request = $this->createMock(Request::class);
$request->method('getClientIp')->willReturn($ip);
return $request;
}
}

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security;
use App\Framework\Http\Method;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Middlewares\CsrfMiddleware;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Security\CsrfToken;
use PHPUnit\Framework\TestCase;
/**
* Critical Security Tests for CSRF Protection
* Tests gegen OWASP A01:2021 Broken Access Control
*/
final class CsrfSecurityTest extends TestCase
{
private SessionInterface $session;
private CsrfMiddleware $middleware;
private RequestStateManager $stateManager;
protected function setUp(): void
{
$this->session = $this->createMock(SessionInterface::class);
$this->middleware = new CsrfMiddleware($this->session);
$this->stateManager = $this->createMock(RequestStateManager::class);
}
/**
* Test: CSRF-Schutz für state-changing Operations
* OWASP A01:2021 Broken Access Control
*/
public function test_csrf_protection_blocks_post_without_token(): void
{
// Arrange: Session ist gestartet aber kein CSRF Token
$this->session->method('isStarted')->willReturn(true);
$request = $this->createPostRequest([], []);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Exception erwartet
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('CSRF protection requires both form ID and token');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: CSRF-Token Validation gegen Token-Forgery
*/
public function test_csrf_validation_rejects_invalid_token(): void
{
// Arrange: Ungültiges Token
$this->session->method('isStarted')->willReturn(true);
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
$csrf->method('validateToken')->willReturn(false);
$this->session->csrf = $csrf;
$request = $this->createPostRequest([
'_form_id' => 'login_form',
'_token' => 'invalid_token_value',
], []);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('CSRF token validation failed');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: Valide CSRF-Token werden akzeptiert
*/
public function test_csrf_validation_accepts_valid_token(): void
{
// Arrange: Valides Token
$this->session->method('isStarted')->willReturn(true);
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
$csrf->method('validateToken')
->with('login_form', $this->isInstanceOf(CsrfToken::class))
->willReturn(true);
$this->session->csrf = $csrf;
$request = $this->createPostRequest([
'_form_id' => 'login_form',
'_token' => 'valid_token_abc123',
], []);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
$next->expects($this->once())->method('__invoke')->willReturn($context);
// Act: Sollte ohne Exception durchlaufen
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$this->assertSame($context, $result);
}
/**
* Test: GET-Requests benötigen keinen CSRF-Schutz
*/
public function test_get_requests_bypass_csrf_validation(): void
{
// Arrange: GET Request ohne Token
$this->session->method('isStarted')->willReturn(true);
$request = $this->createGetRequest();
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
$next->expects($this->once())->method('__invoke')->willReturn($context);
// Act: Sollte ohne Validation durchlaufen
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$this->assertSame($context, $result);
}
/**
* Test: Session muss vor CSRF-Validation gestartet sein
* Verhindert Race Conditions
*/
public function test_requires_started_session(): void
{
// Arrange: Session nicht gestartet
$this->session->method('isStarted')->willReturn(false);
$request = $this->createPostRequest(['_token' => 'abc'], []);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Session must be started before CSRF validation');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
/**
* Test: CSRF-Token über HTTP Headers (AJAX)
*/
public function test_csrf_token_via_headers(): void
{
// Arrange: Token über Headers statt Form Data
$this->session->method('isStarted')->willReturn(true);
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
$csrf->method('validateToken')->willReturn(true);
$this->session->csrf = $csrf;
$request = $this->createPostRequest([], [
'X-CSRF-Form-ID' => 'ajax_form',
'X-CSRF-Token' => 'header_token_xyz',
]);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
$next->expects($this->once())->method('__invoke')->willReturn($context);
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$this->assertSame($context, $result);
}
/**
* Test: Malformed CSRF Token wird abgelehnt
*/
public function test_malformed_csrf_token_rejected(): void
{
// Arrange: Malformed Token
$this->session->method('isStarted')->willReturn(true);
$request = $this->createPostRequest([
'_form_id' => 'form1',
'_token' => 'malformed token with spaces and @#$%',
], []);
$context = new MiddlewareContext($request);
$next = $this->createMock(Next::class);
// Act & Assert: Exception bei malformed Token
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid CSRF token format');
$this->middleware->__invoke($context, $next, $this->stateManager);
}
// Helper Methods
private function createPostRequest(array $body, array $headers): Request
{
$request = $this->createMock(Request::class);
$request->method = Method::POST;
$parsedBody = $this->createMock(\App\Framework\Http\RequestBody::class);
$parsedBody->method('get')->willReturnCallback(fn ($key) => $body[$key] ?? null);
$request->parsedBody = $parsedBody;
$headersBag = $this->createMock(\App\Framework\Http\Headers::class);
$headersBag->method('getFirst')->willReturnCallback(fn ($key) => $headers[$key] ?? null);
$request->headers = $headersBag;
return $request;
}
private function createGetRequest(): Request
{
$request = $this->createMock(Request::class);
$request->method = Method::GET;
return $request;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security\RequestSigning;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\RequestManipulator;
use App\Framework\Security\RequestSigning\RequestSigner;
use App\Framework\Security\RequestSigning\SigningAlgorithm;
use App\Framework\Security\RequestSigning\SigningKey;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RequestSignerTest extends TestCase
{
private RequestSigner $signer;
private RequestManipulator $requestManipulator;
protected function setUp(): void
{
$this->requestManipulator = new RequestManipulator();
$this->signer = new RequestSigner($this->requestManipulator);
}
#[Test]
public function it_can_sign_a_request_with_hmac_sha256(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers(['Host' => 'example.com', 'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT'])
);
$signature = $this->signer->sign($request, $key);
$this->assertEquals('test-key', $signature->keyId);
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $signature->algorithm);
$this->assertNotEmpty($signature->signature);
$this->assertEquals(['(request-target)', 'host', 'date'], $signature->headers);
}
#[Test]
public function it_can_sign_request_with_custom_headers(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
$request = new HttpRequest(
method: Method::POST,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Content-Type' => 'application/json',
]),
body: '{"test": "data"}'
);
$customHeaders = ['(request-target)', 'host', 'date', 'content-type'];
$signature = $this->signer->sign($request, $key, $customHeaders);
$this->assertEquals($customHeaders, $signature->headers);
}
#[Test]
public function it_can_create_digest_for_request_body(): void
{
$body = '{"test": "data"}';
$digest = $this->signer->createDigest($body);
$expectedHash = base64_encode(hash('sha256', $body, true));
$this->assertEquals("SHA256={$expectedHash}", $digest);
}
#[Test]
public function it_can_sign_complete_request_with_body(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
$request = new HttpRequest(
method: Method::POST,
path: '/api/test',
headers: new Headers(['Host' => 'example.com']),
body: '{"test": "data"}'
);
$signedRequest = $this->signer->signRequest($request, $key);
// Should have added Date and Digest headers
$this->assertNotNull($signedRequest->headers->getFirst('Date'));
$this->assertNotNull($signedRequest->headers->getFirst('Digest'));
$this->assertNotNull($signedRequest->headers->getFirst('Signature'));
// Digest should be correct
$expectedHash = base64_encode(hash('sha256', $request->body, true));
$this->assertEquals("SHA256={$expectedHash}", $signedRequest->headers->getFirst('Digest'));
}
#[Test]
public function it_throws_exception_for_invalid_key(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Signing key is not valid');
$key = SigningKey::createHmac('expired-key', 'secret-that-is-long-enough-for-security', expiresAt: new \DateTimeImmutable('2020-01-01'));
$request = new HttpRequest(method: Method::GET, path: '/test');
$this->signer->sign($request, $key);
}
#[Test]
public function it_can_generate_rsa_signature(): void
{
// Generate test RSA key pair
$keyPair = openssl_pkey_new([
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($keyPair, $privateKey);
$key = SigningKey::createRsa('rsa-test-key', $privateKey);
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers(['Host' => 'example.com', 'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT'])
);
$signature = $this->signer->sign($request, $key);
$this->assertEquals('rsa-test-key', $signature->keyId);
$this->assertEquals(SigningAlgorithm::RSA_SHA256, $signature->algorithm);
$this->assertNotEmpty($signature->signature);
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security\RequestSigning;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Security\RequestSigning\InMemorySigningKeyRepository;
use App\Framework\Security\RequestSigning\RequestVerifier;
use App\Framework\Security\RequestSigning\SigningKey;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RequestVerifierTest extends TestCase
{
private RequestVerifier $verifier;
private InMemorySigningKeyRepository $keyRepository;
protected function setUp(): void
{
$this->keyRepository = new InMemorySigningKeyRepository();
$this->verifier = new RequestVerifier($this->keyRepository);
}
#[Test]
public function it_can_verify_valid_hmac_signature(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
$this->keyRepository->store($key);
// Manually create a valid signature for testing
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Signature' => 'keyId="test-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="mock-signature"',
])
);
// We need to mock the signature creation to match what would be generated
$signingString = "(request-target): get /api/test\nhost: example.com\ndate: Thu, 05 Jan 2023 21:31:40 GMT";
$expectedSignature = base64_encode(hash_hmac('sha256', $signingString, 'my-secret-key-that-is-long-enough-for-security', true));
$validRequest = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Signature' => "keyId=\"test-key\",algorithm=\"hmac-sha256\",headers=\"(request-target) host date\",signature=\"{$expectedSignature}\"",
])
);
$result = $this->verifier->verify($validRequest);
$this->assertTrue($result->isSuccess());
$this->assertEquals('test-key', $result->signature->keyId);
}
#[Test]
public function it_fails_verification_for_missing_signature(): void
{
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers(['Host' => 'example.com'])
);
$result = $this->verifier->verify($request);
$this->assertTrue($result->isFailure());
$this->assertEquals('Missing Signature header', $result->errorMessage);
}
#[Test]
public function it_fails_verification_for_unknown_key(): void
{
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Signature' => 'keyId="unknown-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="test-signature"',
])
);
$result = $this->verifier->verify($request);
$this->assertTrue($result->isFailure());
$this->assertEquals('Unknown key ID: unknown-key', $result->errorMessage);
}
#[Test]
public function it_fails_verification_for_invalid_signature(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
$this->keyRepository->store($key);
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Signature' => 'keyId="test-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="invalid-signature"',
])
);
$result = $this->verifier->verify($request);
$this->assertTrue($result->isFailure());
$this->assertEquals('Invalid signature', $result->errorMessage);
}
#[Test]
public function it_can_verify_digest_header(): void
{
$body = '{"test": "data"}';
$expectedHash = base64_encode(hash('sha256', $body, true));
$request = new HttpRequest(
method: Method::POST,
path: '/api/test',
headers: new Headers([
'Digest' => "SHA256={$expectedHash}",
]),
body: $body
);
$this->assertTrue($this->verifier->verifyDigest($request));
}
#[Test]
public function it_fails_digest_verification_for_wrong_hash(): void
{
$request = new HttpRequest(
method: Method::POST,
path: '/api/test',
headers: new Headers([
'Digest' => 'SHA256=wrong-hash',
]),
body: '{"test": "data"}'
);
$this->assertFalse($this->verifier->verifyDigest($request));
}
#[Test]
public function it_passes_digest_verification_when_no_digest_header(): void
{
$request = new HttpRequest(
method: Method::POST,
path: '/api/test',
body: '{"test": "data"}'
);
$this->assertTrue($this->verifier->verifyDigest($request));
}
#[Test]
public function it_fails_verification_for_expired_key(): void
{
$expiredKey = SigningKey::createHmac(
'expired-key',
'secret-that-is-long-enough-for-security',
expiresAt: new \DateTimeImmutable('2020-01-01')
);
$this->keyRepository->store($expiredKey);
$request = new HttpRequest(
method: Method::GET,
path: '/api/test',
headers: new Headers([
'Host' => 'example.com',
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
'Signature' => 'keyId="expired-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="test-signature"',
])
);
$result = $this->verifier->verify($request);
$this->assertTrue($result->isFailure());
$this->assertEquals('Signing key is not valid', $result->errorMessage);
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security\RequestSigning;
use App\Framework\Security\RequestSigning\SigningAlgorithm;
use App\Framework\Security\RequestSigning\SigningKey;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SigningKeyTest extends TestCase
{
#[Test]
public function it_can_create_hmac_key(): void
{
$key = SigningKey::createHmac('test-key', 'my-secret-that-is-long-enough-for-security');
$this->assertEquals('test-key', $key->keyId);
$this->assertEquals('my-secret-that-is-long-enough-for-security', $key->keyMaterial);
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $key->algorithm);
$this->assertTrue($key->isActive);
$this->assertNull($key->expiresAt);
}
#[Test]
public function it_can_create_hmac_key_with_expiration(): void
{
$expiresAt = new \DateTimeImmutable('+1 hour');
$key = SigningKey::createHmac(
'test-key',
'my-secret-that-is-long-enough-for-security',
SigningAlgorithm::HMAC_SHA512,
$expiresAt
);
$this->assertEquals(SigningAlgorithm::HMAC_SHA512, $key->algorithm);
$this->assertEquals($expiresAt, $key->expiresAt);
$this->assertFalse($key->isExpired());
}
#[Test]
public function it_can_generate_random_hmac_key(): void
{
$key = SigningKey::generateHmac('random-key');
$this->assertEquals('random-key', $key->keyId);
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $key->algorithm);
$this->assertGreaterThanOrEqual(64, strlen($key->keyMaterial)); // 32 bytes = 64 hex chars
$this->assertTrue($key->isValid());
}
#[Test]
public function it_can_create_rsa_key(): void
{
// Generate test RSA key pair
$keyPair = openssl_pkey_new([
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($keyPair, $privateKey);
$key = SigningKey::createRsa('rsa-key', $privateKey);
$this->assertEquals('rsa-key', $key->keyId);
$this->assertEquals(SigningAlgorithm::RSA_SHA256, $key->algorithm);
$this->assertEquals($privateKey, $key->keyMaterial);
$this->assertTrue($key->isValid());
}
#[Test]
public function it_throws_exception_for_short_key_material(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Key material must be at least 32 bytes long');
new SigningKey('test', 'short-key', SigningAlgorithm::HMAC_SHA256);
}
#[Test]
public function it_throws_exception_for_invalid_rsa_key(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid RSA private key');
SigningKey::createRsa('invalid-rsa', 'not-a-valid-rsa-key');
}
#[Test]
public function it_detects_expired_keys(): void
{
$expiredKey = SigningKey::createHmac(
'expired-key',
'my-secret-that-is-long-enough-for-security',
expiresAt: new \DateTimeImmutable('2020-01-01')
);
$this->assertTrue($expiredKey->isExpired());
$this->assertFalse($expiredKey->isValid());
}
#[Test]
public function it_validates_non_expired_keys(): void
{
$futureKey = SigningKey::createHmac(
'future-key',
'my-secret-that-is-long-enough-for-security',
expiresAt: new \DateTimeImmutable('+1 hour')
);
$this->assertFalse($futureKey->isExpired());
$this->assertTrue($futureKey->isValid());
}
#[Test]
public function it_handles_keys_without_expiration(): void
{
$permanentKey = SigningKey::createHmac(
'permanent-key',
'my-secret-that-is-long-enough-for-security'
);
$this->assertFalse($permanentKey->isExpired());
$this->assertTrue($permanentKey->isValid());
}
#[Test]
public function it_throws_exception_for_asymmetric_algorithm_with_hmac_factory(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Algorithm must be symmetric for HMAC keys');
SigningKey::createHmac('test', 'my-secret-that-is-long-enough-for-security', SigningAlgorithm::RSA_SHA256);
}
#[Test]
public function it_throws_exception_for_asymmetric_algorithm_with_generate_factory(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Algorithm must be symmetric for HMAC keys');
SigningKey::generateHmac('test', SigningAlgorithm::RSA_SHA256);
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Security;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Middlewares\SecurityHeaderConfig;
use App\Framework\Http\Middlewares\SecurityHeaderMiddleware;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Status;
use PHPUnit\Framework\TestCase;
/**
* Critical Security Tests for HTTP Security Headers
* Tests gegen OWASP A05:2021 Security Misconfiguration
*/
final class SecurityHeadersTest extends TestCase
{
private SecurityHeaderConfig $config;
private SecurityHeaderMiddleware $middleware;
private RequestStateManager $stateManager;
protected function setUp(): void
{
$this->config = new SecurityHeaderConfig(
hsts: true,
hstsMaxAge: 31536000,
hstsIncludeSubdomains: true,
hstsPreload: true,
csp: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
xFrameOptions: 'DENY',
xContentTypeOptions: true,
referrerPolicy: 'strict-origin-when-cross-origin',
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
);
$this->middleware = new SecurityHeaderMiddleware($this->config);
$this->stateManager = $this->createMock(RequestStateManager::class);
}
/**
* Test: HSTS Header wird korrekt gesetzt
* Verhindert SSL-Stripping Attacks
*/
public function test_hsts_header_set_correctly(): void
{
// Arrange
$request = $this->createHttpsRequest();
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$this->assertEquals(
'max-age=31536000; includeSubDomains; preload',
$headers->getFirst('Strict-Transport-Security')
);
}
/**
* Test: CSP Header verhindert XSS
* OWASP A03:2021 Injection
*/
public function test_csp_header_prevents_xss(): void
{
// Arrange
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$cspHeader = $headers->getFirst('Content-Security-Policy');
$this->assertStringContains("default-src 'self'", $cspHeader);
$this->assertStringContains("script-src 'self'", $cspHeader);
$this->assertStringNotContains("'unsafe-eval'", $cspHeader);
}
/**
* Test: X-Frame-Options verhindert Clickjacking
* OWASP A05:2021 Security Misconfiguration
*/
public function test_x_frame_options_prevents_clickjacking(): void
{
// Arrange
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$this->assertEquals('DENY', $headers->getFirst('X-Frame-Options'));
}
/**
* Test: X-Content-Type-Options verhindert MIME-Type Confusion
*/
public function test_x_content_type_options_prevents_mime_sniffing(): void
{
// Arrange
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$this->assertEquals('nosniff', $headers->getFirst('X-Content-Type-Options'));
}
/**
* Test: Referrer Policy schützt vor Information Leakage
*/
public function test_referrer_policy_prevents_info_leakage(): void
{
// Arrange
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$this->assertEquals(
'strict-origin-when-cross-origin',
$headers->getFirst('Referrer-Policy')
);
}
/**
* Test: Permissions Policy beschränkt Browser-Features
*/
public function test_permissions_policy_restricts_features(): void
{
// Arrange
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert
$headers = $result->response->headers;
$permissionsPolicy = $headers->getFirst('Permissions-Policy');
$this->assertStringContains('camera=()', $permissionsPolicy);
$this->assertStringContains('microphone=()', $permissionsPolicy);
$this->assertStringContains('geolocation=()', $permissionsPolicy);
}
/**
* Test: HSTS wird nur über HTTPS gesetzt
* Verhindert Mixed Content Probleme
*/
public function test_hsts_only_over_https(): void
{
// Arrange: HTTP Request
$request = $this->createHttpRequest();
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert: Kein HSTS Header über HTTP
$headers = $result->response->headers;
$this->assertNull($headers->getFirst('Strict-Transport-Security'));
}
/**
* Test: Security Headers werden nicht überschrieben
* Respektiert explizit gesetzte Headers
*/
public function test_existing_security_headers_not_overridden(): void
{
// Arrange: Response mit bereits gesetztem CSP Header
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$existingHeaders = ['Content-Security-Policy' => ["default-src 'none'"]];
$response = new HttpResponse(Status::OK, $existingHeaders, 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
// Assert: Ursprünglicher CSP Header bleibt erhalten
$headers = $result->response->headers;
$this->assertEquals("default-src 'none'", $headers->getFirst('Content-Security-Policy'));
}
/**
* Test: Development-spezifische CSP Policy
* Erlaubt eval() und inline styles in Development
*/
public function test_development_csp_policy(): void
{
// Arrange: Development Config
$devConfig = new SecurityHeaderConfig(
csp: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
);
$middleware = new SecurityHeaderMiddleware($devConfig);
$request = $this->createMock(Request::class);
$context = new MiddlewareContext($request);
$response = new HttpResponse(Status::OK, [], 'content');
$next = $this->createMock(Next::class);
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
// Act
$result = $middleware->__invoke($context, $next, $this->stateManager);
// Assert: Development CSP erlaubt unsafe-eval
$headers = $result->response->headers;
$cspHeader = $headers->getFirst('Content-Security-Policy');
$this->assertStringContains("'unsafe-eval'", $cspHeader);
$this->assertStringContains("'unsafe-inline'", $cspHeader);
}
// Helper Methods
private function createHttpsRequest(): Request
{
$request = $this->createMock(Request::class);
$request->method('isSecure')->willReturn(true);
return $request;
}
private function createHttpRequest(): Request
{
$request = $this->createMock(Request::class);
$request->method('isSecure')->willReturn(false);
return $request;
}
}