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"; } } public function testWatermarkUpload() { $this->section("Upload - Watermark"); // Reset rate limiting array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Get a score to upload to $scores = $this->request('GET', 'scores'); if (empty($scores['body']['scores'])) { echo " ⚠ No scores available for watermark test\n"; return; } $scoreId = $scores['body']['scores'][0]['id']; // Create a minimal test PDF $pdfContent = "%PDF-1.4 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /Resources 4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >> endobj 4 0 obj << /Font << /F1 6 0 R >> >> endobj 5 0 obj << /Length 44 >> stream BT /F1 12 Tf 100 700 Td (Test) Tj ET endstream endobj 6 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj xref 0 7 0000000000 65535 f 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n 0000000214 00000 n 0000000312 00000 n 0000000401 00000 n trailer << /Size 7 /Root 1 0 R >> startxref 490 %%EOF"; $tmpFile = sys_get_temp_dir() . '/test_watermark_' . time() . '.pdf'; file_put_contents($tmpFile, $pdfContent); // Test upload with watermark left $result = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpFile], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => 'dir'], ['name' => 'version', 'data' => '1'], ['name' => 'watermark', 'data' => 'true'], ['name' => 'watermarkPosition', 'data' => 'left'] ]); $this->test('Upload with watermark left returns 200', $result['code'] === 200); // Get files to verify it was uploaded $files = $this->request('GET', "admin/scores/$scoreId/files"); $hasFile = false; if (!empty($files['body']['files'])) { foreach ($files['body']['files'] as $f) { if (strpos($f['path'], 'direction_1.pdf') !== false) { $hasFile = true; // Clean up @unlink(SCORES_PATH . "$scoreId/" . $f['path']); break; } } } // Test upload with watermark right $result2 = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpFile], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => 'dir'], ['name' => 'version', 'data' => '2'], ['name' => 'watermark', 'data' => 'true'], ['name' => 'watermarkPosition', 'data' => 'right'] ]); $this->test('Upload with watermark right returns 200', $result2['code'] === 200); // Clean up @unlink($tmpFile); // Clean up uploaded files $files = $this->request('GET', "admin/scores/$scoreId/files"); if (!empty($files['body']['files'])) { foreach ($files['body']['files'] as $f) { if (strpos($f['path'], 'direction_') !== false) { @unlink(SCORES_PATH . "$scoreId/" . $f['path']); } } } } private function multipartRequest($endpoint, $fields) { $url = BASE_URL . '/' . $endpoint; $boundary = '----WebKitFormBoundary' . bin2hex(random_bytes(8)); $body = ''; foreach ($fields as $field) { $body .= "--$boundary\r\n"; if (isset($field['file'])) { $filename = basename($field['file']); $content = file_get_contents($field['file']); $body .= "Content-Disposition: form-data; name=\"{$field['name']}\"; filename=\"$filename\"\r\n"; $body .= "Content-Type: application/pdf\r\n\r\n"; $body .= $content . "\r\n"; } else { $body .= "Content-Disposition: form-data; name=\"{$field['name']}\"\r\n\r\n"; $body .= $field['data'] . "\r\n"; } } $body .= "--$boundary--\r\n"; $headers = [ 'Content-Type: multipart/form-data; boundary=' . $boundary, 'Authorization: Bearer ' . $this->token ]; $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body ] ]); $response = @file_get_contents($url, false, $context); $headers_list = $http_response_header ?? []; $code = 0; foreach ($headers_list as $header) { if (preg_match('/^HTTP\/\d+(\.\d+)?\s+(\d{3})/', $header, $matches)) { $code = (int)$matches[2]; } } return [ 'code' => $code, 'body' => json_decode($response, true) ?? [] ]; } // ============ 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 autorisé $url = BASE_URL . '/login'; $context = stream_context_create([ 'http' => [ 'method' => 'OPTIONS', 'header' => "Origin: http://localhost:5173\r\nAccess-Control-Request-Method: POST", 'ignore_errors' => true ] ]); @file_get_contents($url, false, $context); $headers = $http_response_header ?? []; $hasCorrectOrigin = false; foreach ($headers as $header) { if (stripos($header, 'Access-Control-Allow-Origin: http://localhost:5173') !== false) { $hasCorrectOrigin = true; } } $this->test('CORS allows localhost:5173', $hasCorrectOrigin); } 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(); $tests->testWatermarkUpload(); // Security tests $tests->testSecurityHeaders(); $tests->testCorsPolicy(); $tests->testDirectoryTraversal(); $tests->testRateLimiting(); $tests->testFileUploadSecurity(); $tests->testInjectionAttacks(); $tests->testHttpsEnforcement(); // Summary $tests->summary();