token) { $headers[] = 'Authorization: Bearer ' . $this->token; } $context = stream_context_create([ 'http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $data ? json_encode($data) : null, 'ignore_errors' => true ] ]); // Capture response headers globally global $last_response_headers; $response = @file_get_contents($url, false, $context); $last_response_headers = $http_response_header ?? []; $httpCode = 500; foreach ($http_response_header ?? [] as $header) { if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $matches)) { $httpCode = (int)$matches[1]; } } return [ 'code' => $httpCode, 'body' => json_decode($response, true) ?? [] ]; } public function test($name, $condition) { if ($condition) { echo "✓ $name\n"; $this->passed++; } else { echo "✗ $name\n"; $this->failed++; } } public function section($name) { echo "\n--- $name ---\n"; } // ============ AUTH TESTS ============ public function testAuthErrors() { $this->section("Auth - Error Handling"); // Bad credentials $result = $this->request('POST', 'login', ['username' => 'admin', 'password' => 'wrong'], false); $this->test('Bad password returns 401', $result['code'] === 401); // Missing credentials $result = $this->request('POST', 'login', ['username' => '', 'password' => ''], false); $this->test('Empty credentials returns 401', $result['code'] === 401); // Unknown user $result = $this->request('POST', 'login', ['username' => 'unknown', 'password' => 'pass'], false); $this->test('Unknown user returns 401', $result['code'] === 401); // Access without token $result = $this->request('GET', 'scores', null, false); $this->test('Access without token returns 401', $result['code'] === 401); // Invalid token $result = $this->request('GET', 'scores', null, true); $this->test('Invalid token returns 401', $result['code'] === 401); } public function testAuthSuccess() { $this->section("Auth - Success"); $result = $this->request('POST', 'login', ['username' => 'admin', 'password' => 'password'], false); $this->test('Login returns 200', $result['code'] === 200); if ($result['code'] === 200) { $this->token = $result['body']['token'] ?? null; $this->test('Token received', !empty($this->token)); $this->test('User role is admin', ($result['body']['user']['role'] ?? '') === 'admin'); } } // ============ SCORES TESTS ============ public function testScoresErrors() { $this->section("Scores - Error Handling"); // Get non-existent score $result = $this->request('GET', 'scores/999999'); $this->test('Non-existent score returns 404', $result['code'] === 404); // Update non-existent score $result = $this->request('PUT', 'scores/999999', ['name' => 'Test']); $this->test('Update non-existent returns 404', $result['code'] === 404); // Delete non-existent score $result = $this->request('DELETE', 'admin/scores/999999'); $this->test('Delete non-existent returns 404 or 400', $result['code'] === 404 || $result['code'] === 400); // Create score without name $result = $this->request('POST', 'admin/scores', ['name' => '', 'compositor' => 'Test']); $this->test('Create without name returns 400', $result['code'] === 400); } public function testScoresSuccess() { $this->section("Scores - Functional"); // Get all scores $result = $this->request('GET', 'scores'); $this->test('Get scores returns 200', $result['code'] === 200); $this->test('Response has scores array', isset($result['body']['scores'])); if (!empty($result['body']['scores'])) { $scoreId = $result['body']['scores'][0]['id']; // Get single score $result = $this->request('GET', "scores/$scoreId"); $this->test('Get single score returns 200', $result['code'] === 200); $this->test('Score has id', isset($result['body']['score']['id'])); $this->test('Score has name', isset($result['body']['score']['name'])); // Get pieces $result = $this->request('GET', "pieces/$scoreId"); $this->test('Get pieces returns 200', $result['code'] === 200); $this->test('Response has pieces array', isset($result['body']['pieces'])); } } // ============ CREATE SCORE WITH PIECES TESTS ============ public function testCreateScoreWithPieces() { $this->section("Create Score with Pieces - Functional"); // Test 1: Create score with 2 pieces $testName = 'Test Score ' . date('YmdHis'); $result = $this->request('POST', 'admin/scores', [ 'name' => $testName, 'compositor' => 'Test Composer', 'pieces' => [ ['number' => 1, 'name' => 'Allegro'], ['number' => 2, 'name' => 'Adagio'] ] ]); $this->test('Create with pieces returns 200', $result['code'] === 200); if ($result['code'] === 200 && isset($result['body']['score']['id'])) { $scoreId = $result['body']['score']['id']; echo " Created: $scoreId\n"; // Verify score.ini $iniPath = SCORES_PATH . $scoreId . '/score.ini'; if (file_exists($iniPath)) { $ini = parse_ini_file($iniPath, true); $this->test('Piece count is 2', ($ini['pieces']['count'] ?? '') === '2'); $this->test('Piece 1 name saved', ($ini['pieces']['1'] ?? '') === 'Allegro'); $this->test('Piece 2 name saved', ($ini['pieces']['2'] ?? '') === 'Adagio'); } // Verify pieces API $result = $this->request('GET', "pieces/$scoreId"); if ($result['code'] === 200) { $pieces = $result['body']['pieces'] ?? []; $this->test('API returns 2 pieces', count($pieces) === 2); $this->test('Piece 1 has correct name', ($pieces[0]['name'] ?? '') === 'Allegro'); $this->test('Piece 2 has correct name', ($pieces[1]['name'] ?? '') === 'Adagio'); } // Cleanup $this->request('DELETE', "admin/scores/$scoreId"); echo " Cleaned up\n"; } // Test 2: Create score with 1 piece (default) $testName = 'Test Score Default ' . date('YmdHis'); $result = $this->request('POST', 'admin/scores', [ 'name' => $testName, 'compositor' => 'Test Composer' ]); $this->test('Create default returns 200', $result['code'] === 200); if ($result['code'] === 200 && isset($result['body']['score']['id'])) { $scoreId = $result['body']['score']['id']; $iniPath = SCORES_PATH . $scoreId . '/score.ini'; if (file_exists($iniPath)) { $ini = parse_ini_file($iniPath, true); $this->test('Default piece count is 1', ($ini['pieces']['count'] ?? '') === '1'); } $this->request('DELETE', "admin/scores/$scoreId"); } } // ============ FILES TESTS ============ public function testFilesErrors() { $this->section("Files - Error Handling"); // Get files for non-existent score $result = $this->request('GET', 'admin/scores/999999/files'); $this->test('Get files non-existent score returns 400', $result['code'] === 400); // Delete without path $scores = $this->request('GET', 'scores'); if (!empty($scores['body']['scores'])) { $scoreId = $scores['body']['scores'][0]['id']; $result = $this->request('DELETE', "admin/scores/$scoreId/files"); $this->test('Delete without path returns 400', $result['code'] === 400); // Delete non-existent file $result = $this->request('DELETE', "admin/scores/$scoreId/files?path=nonexistent.pdf"); $this->test('Delete non-existent file returns 400', $result['code'] === 400); } } public function testFilesSuccess() { $this->section("Files - Functional"); // Get files for a score $scores = $this->request('GET', 'scores'); if (!empty($scores['body']['scores'])) { $scoreId = $scores['body']['scores'][0]['id']; $result = $this->request('GET', "admin/scores/$scoreId/files"); $this->test('Get files returns 200', $result['code'] === 200); $this->test('Response has files array', isset($result['body']['files'])); $this->test('Files is array', is_array($result['body']['files'])); // Test structure $files = $result['body']['files']; if (!empty($files)) { $this->test('Files have name property', isset($files[0]['name'])); $this->test('Files have path property', isset($files[0]['path'])); $this->test('Files have type property', isset($files[0]['type'])); } } else { echo " ⚠ No scores available for file tests\n"; } } // ============ SECURITY TESTS ============ public function testSecurityHeaders() { $this->section("Security - HTTP Headers"); global $last_response_headers; $result = $this->request('GET', 'scores', null, false); $headers = $last_response_headers ?? []; $hasXContentType = false; $hasXFrameOptions = false; $hasXXSS = false; $hasHSTS = false; foreach ($headers as $header) { if (stripos($header, 'X-Content-Type-Options:') !== false) $hasXContentType = true; if (stripos($header, 'X-Frame-Options:') !== false) $hasXFrameOptions = true; if (stripos($header, 'X-XSS-Protection:') !== false) $hasXXSS = true; if (stripos($header, 'Strict-Transport-Security:') !== false) $hasHSTS = true; } $this->test('X-Content-Type-Options header present', $hasXContentType); $this->test('X-Frame-Options header present', $hasXFrameOptions); $this->test('X-XSS-Protection header present', $hasXXSS); $this->test('Strict-Transport-Security header present', $hasHSTS); } public function testCorsPolicy() { $this->section("Security - CORS Policy"); // Test avec Origin non autorisé $url = BASE_URL . '/login'; $context = stream_context_create([ 'http' => [ 'method' => 'OPTIONS', 'header' => "Origin: https://evil.com\r\nAccess-Control-Request-Method: POST", 'ignore_errors' => true ] ]); @file_get_contents($url, false, $context); $headers = $http_response_header ?? []; $hasWildcard = false; foreach ($headers as $header) { if (stripos($header, 'Access-Control-Allow-Origin: *') !== false) { $hasWildcard = true; } } $this->test('CORS does not allow wildcard (*)', !$hasWildcard); } public function testDirectoryTraversal() { $this->section("Security - Directory Traversal"); // Ensure we have a valid token for these tests if (!$this->token) { // Clear rate limiting and login fresh array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); $result = $this->request('POST', 'login', ['username' => 'admin', 'password' => 'password'], false); if ($result['code'] === 200 && isset($result['body']['token'])) { $this->token = $result['body']['token']; } } // Clear rate limiting to avoid blocking array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Test 1: Download avec path traversal $result = $this->request('GET', 'download/../../../etc/passwd', null, true); // Accept 401 (no token), 403 (blocked), or 404 (path not found after sanitization) $blocked = in_array($result['code'], [401, 403, 404]); $this->test('Directory traversal blocked (../)', $blocked); // Clear rate limiting between tests array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Test 2: Download avec double encoding $result = $this->request('GET', 'download/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd', null, true); $blocked = in_array($result['code'], [401, 403, 404]); $this->test('Directory traversal blocked (URL encoded)', $blocked); // Clear rate limiting between tests array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Test 3: Download avec null byte (si PHP < 5.3.4) $result = $this->request('GET', "download/file.pdf%00.jpg", null, true); $blocked = in_array($result['code'], [400, 401, 403]); $this->test('Null byte injection blocked', $blocked); } public function testRateLimiting() { $this->section("Security - Rate Limiting"); // Clear any existing rate limiting before test array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Test login brute force $attempts = 0; $blocked = false; for ($i = 0; $i < 10; $i++) { $result = $this->request('POST', 'login', ['username' => 'admin', 'password' => 'wrong'], false); if ($result['code'] === 429) { $blocked = true; break; } $attempts++; } $this->test('Rate limiting active (429 after attempts)', $blocked); $this->test('Rate limit triggered within 10 attempts', $attempts < 10); // IMPORTANT: Clear rate limiting after this test so subsequent tests aren't blocked array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); } public function testFileUploadSecurity() { $this->section("Security - File Upload"); // Completely reset rate limiting array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Test directly on an existing score (001 should exist) // This avoids needing to create a score which might be blocked $result = $this->request('POST', "admin/scores/001/upload"); // Should return 400 (no file), not 404 (score not found) or 200 (success) $this->test('Upload rejects empty file', $result['code'] === 400); } public function testInjectionAttacks() { $this->section("Security - Injection Protection"); // Test INI injection (newline in name) on a temporary file directly $testName = "Test\n[injection]\nmalicious=true"; $sanitizedName = str_replace(["\n", "\r", "\x00"], '', $testName); // Create a temporary score.ini to test the sanitization $tempDir = sys_get_temp_dir() . '/test_injection_' . time(); mkdir($tempDir); $iniContent = "[info]\nname = $sanitizedName\ncompositor = Test\n\n[pieces]\ncount = 1\n"; file_put_contents($tempDir . '/score.ini', $iniContent); // Verify the injection was prevented by parsing the INI file // If injection worked, we'd have a section called [injection] $parsed = parse_ini_file($tempDir . '/score.ini', true); $hasInjectionSection = isset($parsed['injection']); $this->test('INI injection prevented', !$hasInjectionSection); // Cleanup unlink($tempDir . '/score.ini'); rmdir($tempDir); } public function testHttpsEnforcement() { $this->section("Security - HTTPS Enforcement"); // Test que le serveur redirige HTTP vers HTTPS ou refuse // Note: En local, on vérifie juste le header HSTS global $last_response_headers; $result = $this->request('GET', 'scores', null, false); $headers = $last_response_headers ?? []; $hasHSTS = false; foreach ($headers as $header) { if (stripos($header, 'Strict-Transport-Security:') !== false) { $hasHSTS = true; } } $this->test('HSTS header present for HTTPS enforcement', $hasHSTS); } // ============ SUMMARY ============ public function summary() { echo "\n=== Summary ===\n"; echo "Passed: {$this->passed}\n"; echo "Failed: {$this->failed}\n"; echo "Total: " . ($this->passed + $this->failed) . "\n"; if ($this->failed > 0) { echo "\n⚠ Some tests failed!\n"; } else { echo "\n✓ All tests passed!\n"; } exit($this->failed > 0 ? 1 : 0); } } // Run tests $tests = new APITest(); // Auth tests $tests->testAuthErrors(); $tests->testAuthSuccess(); // Scores tests $tests->testScoresErrors(); $tests->testScoresSuccess(); // Create score with pieces $tests->testCreateScoreWithPieces(); // Files tests $tests->testFilesErrors(); $tests->testFilesSuccess(); // Security tests $tests->testSecurityHeaders(); $tests->testCorsPolicy(); $tests->testDirectoryTraversal(); $tests->testRateLimiting(); $tests->testFileUploadSecurity(); $tests->testInjectionAttacks(); $tests->testHttpsEnforcement(); // Summary $tests->summary();