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 0: Try to create score with existing ID should fail (self-contained) $seed = $this->request('POST', 'admin/scores', ['name' => 'Seed Duplicate']); $seedId = $seed['body']['score']['id'] ?? null; $result = $this->request('POST', 'admin/scores', [ 'name' => 'Test Duplicate', 'compositor' => 'Test', 'id' => $seedId ]); $this->test('Create with existing ID returns error', $result['code'] === 400 && isset($result['body']['error'])); if ($seedId) { $this->request('DELETE', "admin/scores/$seedId"); } // 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_*')); // Create a dedicated score so the test never touches real data $created = $this->request('POST', 'admin/scores', ['name' => 'Watermark Test']); $scoreId = $created['body']['score']['id'] ?? null; if (!$scoreId) { $this->test('Dedicated watermark score available', false); return; } $tmpFile = sys_get_temp_dir() . '/test_watermark_' . uniqid() . '.pdf'; $this->makePdf($tmpFile, 2); // 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 (recursive tree) $files = $this->request('GET', "admin/scores/$scoreId/files"); $found = $this->findTreeFile($files['body']['files'] ?? [], 'direction-1.pdf'); if ($found) { require_once __DIR__ . '/vendor/autoload.php'; $check = new \setasign\Fpdi\Fpdi(); $pages = $check->setSourceFile(SCORES_PATH . "$scoreId/" . $found['path']); $this->test('Watermark preserves all pages', $pages === 2); } else { $this->test('Watermarked file present', false); } // 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 all uploaded files (recursive) then the dedicated score $files = $this->request('GET', "admin/scores/$scoreId/files"); $this->deleteTreeMatching($files['body']['files'] ?? [], $scoreId, 'direction-'); $this->request('DELETE', "admin/scores/$scoreId"); } private function findTreeFile($tree, $needle) { foreach ($tree as $node) { if (($node['type'] ?? '') === 'folder') { $found = $this->findTreeFile($node['children'] ?? [], $needle); if ($found) { return $found; } } elseif (strpos($node['path'] ?? '', $needle) !== false) { return $node; } } return null; } private function deleteTreeMatching($tree, $scoreId, $needle) { foreach ($tree as $node) { if (($node['type'] ?? '') === 'folder') { $this->deleteTreeMatching($node['children'] ?? [], $scoreId, $needle); } elseif (strpos($node['path'] ?? '', $needle) !== false) { @unlink(SCORES_PATH . "$scoreId/" . $node['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 ============ // ============ EXTRA FUNCTIONAL COVERAGE ============ private function requestWithToken($method, $endpoint, $token) { $context = stream_context_create([ 'http' => [ 'method' => $method, 'header' => "Authorization: Bearer $token", 'ignore_errors' => true ] ]); $response = @file_get_contents(BASE_URL . '/' . $endpoint, false, $context); $code = 500; foreach ($http_response_header ?? [] as $header) { if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) { $code = (int) $m[1]; } } return ['code' => $code, 'body' => json_decode($response, true) ?? []]; } public function testMoreEndpoints() { $this->section("Endpoints - Extended"); array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); // Create a dedicated score (auto id) then exercise every sub-resource $created = $this->request('POST', 'admin/scores', ['name' => 'Test Coverage', 'compositor' => 'CI']); $this->test('Create score (auto id) returns 200', $created['code'] === 200); $scoreId = $created['body']['score']['id'] ?? null; if (!$scoreId) { $this->test('Dedicated score available', false); return; } // Invalid id is rejected $bad = $this->request('POST', 'admin/scores', ['id' => '../evil', 'name' => 'x']); $this->test('Create with invalid id returns 400', $bad['code'] === 400); // Upload a valid PDF $tmpPdf = sys_get_temp_dir() . '/cov_' . uniqid() . '.pdf'; $this->makePdf($tmpPdf, 1); $up = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpPdf], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => 'dir'], ['name' => 'version', 'data' => '1'], ]); $this->test('Upload valid PDF returns 200', $up['code'] === 200); // Invalid segment / invalid file type $badSeg = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpPdf], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => '..'], ['name' => 'version', 'data' => '1'], ]); $this->test('Upload invalid instrument returns 400', $badSeg['code'] === 400); $badSeg2 = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpPdf], ['name' => 'piece', 'data' => '..'], ['name' => 'instrument', 'data' => 'dir'], ['name' => 'version', 'data' => '1'], ]); $this->test('Upload invalid piece returns 400', $badSeg2['code'] === 400); $noFile = $this->request('POST', "admin/scores/$scoreId/upload", ['unexpected' => 'body']); $this->test('Upload without file part returns 413', $noFile['code'] === 413); // Combined/superposed parts are named after the combined instruments $supUp = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpPdf], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => 'sup'], ['name' => 'version', 'data' => '1'], ['name' => 'combined', 'data' => 'contrebasse-1+clarinette-1'], ]); $this->test('Upload sup combined returns 200', $supUp['code'] === 200); $this->test('Upload sup combined filename', str_contains($supUp['body']['path'] ?? '', 'contrebasse-1+clarinette-1.pdf')); // Percussion parts are named after the real instrument(s) $perUp = $this->multipartRequest("admin/scores/$scoreId/upload", [ ['name' => 'file', 'file' => $tmpPdf], ['name' => 'piece', 'data' => '1'], ['name' => 'instrument', 'data' => 'per'], ['name' => 'version', 'data' => '1'], ['name' => 'combined', 'data' => 'glockenspiel-1+xylophone-1'], ]); $this->test('Upload per combined returns 200', $perUp['code'] === 200); $this->test('Upload per combined filename', str_contains($perUp['body']['path'] ?? '', 'glockenspiel-1+xylophone-1.pdf')); // Instruments (list + filter) $instr = $this->request('GET', "scores/$scoreId/instruments"); $this->test('Instruments returns 200', $instr['code'] === 200); $instrPiece = $this->request('GET', "scores/$scoreId/instruments", null, true, ['piece' => 1]); $this->test('Instruments?piece=1 returns 200', $instrPiece['code'] === 200); // Pieces endpoint content $pieces = $this->request('GET', "pieces/$scoreId"); $this->test('Pieces returns 200', $pieces['code'] === 200); // Download success + failures $path = "$scoreId/1/dir/1/direction-1.pdf"; $ok = $this->request('GET', "download/$path"); $this->test('Download with token returns 200', $ok['code'] === 200); $noToken = $this->request('GET', "download/$path", null, false); $this->test('Download without token returns 401', $noToken['code'] === 401); $badToken = $this->requestWithToken('GET', "download/$path", 'not.a.token'); $this->test('Download with invalid token returns 401', $badToken['code'] === 401); $nonPdf = $this->request('GET', "download/$scoreId/score.ini"); $this->test('Download non-PDF returns 403', $nonPdf['code'] === 403); $missing = $this->request('GET', "download/$scoreId/1/dir/1/nope.pdf"); $this->test('Download missing file returns 404', $missing['code'] === 404); $badApi = $this->requestWithToken('GET', 'scores', 'a.b.c'); $this->test('Protected route with invalid token returns 401', $badApi['code'] === 401); $root = realpath(SCORES_PATH); @symlink('/etc/hostname', $root . "/$scoreId/evil.pdf"); $escaped = $this->request('GET', "download/$scoreId/evil.pdf"); $this->test('Download escaped symlink returns 403', $escaped['code'] === 403); @unlink($root . "/$scoreId/evil.pdf"); // updatePieces $upd = $this->request('PUT', "admin/scores/$scoreId/pieces", [ 'pieces' => [ ['number' => 1, 'name' => 'Mouvement A'], ['number' => 2, 'name' => 'Mouvement B'] ] ]); $this->test('Update pieces returns 200', $upd['code'] === 200); $badPiece = $this->request('PUT', "admin/scores/$scoreId/pieces", ['pieces' => [['number' => 'x', 'name' => 'bad']]]); $this->test('Update pieces invalid number returns 400', $badPiece['code'] === 400); // updateScore with ressource (persist + invalid) $updScore = $this->request('PUT', "admin/scores/$scoreId", ['name' => 'Test Coverage', 'compositor' => 'CI', 'ressource' => 'https://example.com/x']); $this->test('Update score with ressource returns 200', $updScore['code'] === 200); $score = $this->request('GET', "scores/$scoreId"); $this->test('Ressource persisted', ($score['body']['score']['ressource'] ?? null) === 'https://example.com/x'); $badUrl = $this->request('PUT', "admin/scores/$scoreId", ['ressource' => 'javascript:alert(1)']); $this->test('Update score invalid ressource returns 400', $badUrl['code'] === 400); // Non-admin cannot reach admin routes $userLogin = $this->request('POST', 'login', ['username' => 'user', 'password' => 'password'], false); if (($userLogin['body']['token'] ?? null)) { $saved = $this->token; $this->token = $userLogin['body']['token']; $forbidden = $this->request('POST', 'admin/scores', ['name' => 'nope']); $this->test('Non-admin blocked on admin route (403)', $forbidden['code'] === 403); $this->token = $saved; } // Delete uploaded file, then the score $del = $this->request('DELETE', "admin/scores/$scoreId/files", null, true, ['path' => '1/dir/1/direction-1.pdf']); $this->test('Delete file returns 200', $del['code'] === 200); $delScore = $this->request('DELETE', "admin/scores/$scoreId"); $this->test('Delete score returns 200', $delScore['code'] === 200); // Unknown route $unknown = $this->request('GET', 'does/not/exist'); $this->test('Unknown route returns 404', $unknown['code'] === 404); @unlink($tmpPdf); } // ============ UNIT TESTS (direct library coverage) ============ private function makePdf($path, $pages = 1) { require_once __DIR__ . '/vendor/setasign/fpdf/fpdf.php'; $pdf = new FPDF(); for ($i = 0; $i < $pages; $i++) { $pdf->AddPage(); $pdf->SetFont('Helvetica', '', 12); $pdf->Cell(40, 10, 'Page ' . ($i + 1)); } $pdf->Output('F', $path); } private function rrmdir($dir) { if (!is_dir($dir)) return; foreach (scandir($dir) as $item) { if ($item === '.' || $item === '..') continue; $p = $dir . '/' . $item; is_dir($p) ? $this->rrmdir($p) : @unlink($p); } @rmdir($dir); } public function testUnits() { $this->section("Unit - ScoreScanner & Auth"); require_once __DIR__ . '/lib/ScoreScanner.php'; require_once __DIR__ . '/lib/Auth.php'; $base = sys_get_temp_dir() . '/ohmj_unit_' . uniqid() . '/'; @mkdir($base . '001/1/cla/1', 0777, true); @mkdir($base . '001/1/cor/1', 0777, true); @mkdir($base . '001/1/cor/2', 0777, true); @mkdir($base . '001/1/emt/1', 0777, true); @mkdir($base . '001/2/xyz', 0777, true); @mkdir($base . '002', 0777, true); @mkdir($base . '003', 0777, true); @mkdir($base . '004', 0777, true); @mkdir($base . '.hidden', 0777, true); @mkdir($base . 'notnum', 0777, true); @mkdir($base . '005/1/cla/1', 0777, true); @mkdir($base . '007/cla/1', 0777, true); @mkdir($base . '008', 0777, true); file_put_contents($base . '001/score.ini', "[info]\nname = Test\ncompositor = Comp\nressource = https://example.com\n\n[pieces]\ncount = 1\n"); file_put_contents($base . '002/score.ini', "name = Broken\ncompositor = Someone\nkey = \"unterminated\n"); file_put_contents($base . '004/score.ini', "just text no equals\n"); file_put_contents($base . '008/score.ini', "\"unterminated line\n"); file_put_contents($base . '007/cla/1/flute-ut-1.pdf', '%PDF-1.4 test'); file_put_contents($base . '005/score.ini', "[info]\nname = NoInstruments\n"); $this->makePdf($base . '001/1/cla/1/clarinette-sib-1.pdf', 1); $this->makePdf($base . '001/1/cor/1/cor-fa-1.pdf', 1); $this->makePdf($base . '001/1/cor/2/cor-fa-1.pdf', 1); $this->makePdf($base . '001/1/cla/1/weird.txt', 1); file_put_contents($base . '001/1/cla/1/clarinette-sib-solo-2.pdf', '%PDF-1.4 test'); file_put_contents($base . '001/1/cla/1/clarinette-mib-1_2.pdf', '%PDF-1.4 test'); file_put_contents($base . '001/1/cla/1/readme.txt', 'x'); file_put_contents($base . '001/1/emt/1/fake.txt', 'x'); $scanner = new ScoreScanner($base); $scores = $scanner->listScores(); $this->test('Unit: listScores returns array', is_array($scores) && count($scores) >= 1); $this->test('Unit: getScore returns detail', is_array($scanner->getScore('001'))); $this->test('Unit: getScore unknown returns null', $scanner->getScore('999') === null); $this->test('Unit: getScore no instruments returns detail', is_array($scanner->getScore('005'))); $this->test('Unit: getPieces returns 1 piece', count($scanner->getPieces('001')) === 1); $this->test('Unit: getPieces unknown returns []', $scanner->getPieces('999') === []); $this->test('Unit: getInstruments returns array', is_array($scanner->getInstruments('001'))); $this->test('Unit: getInstruments piece filter', is_array($scanner->getInstruments('001', '1'))); $bad = new ScoreScanner('/nonexistent/path/'); $this->test('Unit: missing scores dir returns error', isset($bad->listScores()['error'])); $this->test('Unit: getScore no ini returns null', $scanner->getScore('003') === null); $this->test('Unit: getPieces parse failure returns []', $scanner->getPieces('002') === []); $this->test('Unit: getInstruments unknown returns []', $scanner->getInstruments('999') === []); // parseFilename branches via reflection $ref = new ReflectionClass('ScoreScanner'); $parse = $ref->getMethod('parseFilename'); $parse->setAccessible(true); $p1 = $parse->invoke($scanner, 'clarinette-sib-1'); $this->test('Unit: parse key+part', $p1['key'] === 'sib' && $p1['part'] === '1'); $p2 = $parse->invoke($scanner, 'clarinette-mib-1_2'); $this->test('Unit: parse combined part', $p2['key'] === 'mib' && $p2['part'] === '1_2'); $p3 = $parse->invoke($scanner, 'cornet-sib-solo-2'); $this->test('Unit: parse variant solo', $p3['variant'] === 'solo'); $p4 = $parse->invoke($scanner, 'cor-fa-1-clesol'); $this->test('Unit: parse clef', $p4['clef'] === 'clesol'); $p5 = $parse->invoke($scanner, 'baryton-sib-default-cor-2'); $this->test('Unit: parse variant default', $p5['variant'] === 'default'); $p6 = $parse->invoke($scanner, 'tuba-ut-1'); $this->test('Unit: parse key ut', $p6['key'] === 'ut'); $getName = $ref->getMethod('getInstrumentName'); $getName->setAccessible(true); $this->test('Unit: instrument name mapping', $getName->invoke($scanner, 'cla') === 'Clarinette'); $this->test('Unit: instrument name fallback', $getName->invoke($scanner, 'zzz') === 'zzz'); $sanitize = $ref->getMethod('sanitizeIniValue'); $sanitize->setAccessible(true); $this->test('Unit: sanitize removes newlines', $sanitize->invoke($scanner, "a\nb\rc") === 'abc'); $valid = $ref->getMethod('isValidSegment'); $valid->setAccessible(true); $this->test('Unit: segment accepts digits', $valid->invoke($scanner, '12', '/^\d+$/') === true); $this->test('Unit: segment rejects traversal', $valid->invoke($scanner, '..', '/^\d+$/') === false); $this->test('Unit: segment rejects empty', $valid->invoke($scanner, '', '/^\d+$/') === false); // Legacy (no piece level) paths via reflection $gii = $ref->getMethod('getInstrumentInfo'); $gii->setAccessible(true); $gpi = $ref->getMethod('getPartInfo'); $gpi->setAccessible(true); $this->test('Unit: getInstrumentInfo missing dir returns null', $gii->invoke($scanner, '999', 'cla') === null); $this->test('Unit: getPartInfo missing dir returns null', $gpi->invoke($scanner, '999', 'cla', '1') === null); $legacy = $gii->invoke($scanner, '007', 'cla'); $this->test('Unit: getInstrumentInfo no-piece path', is_array($legacy) && isset($legacy['parts'][0]['files'][0]['path'])); $this->test('Unit: getInstrumentInfo empty parts returns null', $gii->invoke($scanner, '001', 'emt', '1') === null); // createScore branches $this->test('Unit: createScore invalid id', $scanner->createScore('../x', 'n', 'c')['success'] === false); $this->test('Unit: createScore ok', $scanner->createScore('100', 'New', 'Comp')['success'] === true); $this->test('Unit: createScore duplicate', $scanner->createScore('100', 'New', 'Comp')['success'] === false); $this->test('Unit: createScore invalid piece', $scanner->createScore('101', 'New', 'Comp', [['number' => 'x', 'name' => 'y']])['success'] === false); $fileAsBase = new ScoreScanner($base . '001/score.ini/'); $this->test('Unit: createScore mkdir failure', $fileAsBase->createScore('900', 'n', 'c')['success'] === false); $this->test('Unit: createScore with pieces', $scanner->createScore('102', 'New', 'Comp', [['number' => 1, 'name' => 'A']])['success'] === true); // updateScore branches $this->test('Unit: updateScore unknown', $scanner->updateScore('999', 'a', 'b')['success'] === false); $this->test('Unit: updateScore invalid ressource', $scanner->updateScore('100', null, null, 'javascript:x')['success'] === false); $this->test('Unit: updateScore ok', $scanner->updateScore('100', 'Renamed', 'Comp2', 'https://ok.example')['success'] === true); $read = $ref->getMethod('getScoreInfo'); $read->setAccessible(true); $info = $read->invoke($scanner, '100'); $this->test('Unit: updateScore persisted ressource', ($info['ressource'] ?? '') === 'https://ok.example'); $this->test('Unit: updateScore preserves on partial', $scanner->updateScore('100', 'OnlyName', null)['success'] === true); $info2 = $read->invoke($scanner, '100'); $this->test('Unit: updateScore kept ressource', ($info2['ressource'] ?? '') === 'https://ok.example'); $this->test('Unit: updateScore parse failure', $scanner->updateScore('002', 'x', null)['success'] === false); $scanner->createScore('104', 'W', 'C'); @chmod($base . '104/score.ini', 0444); $this->test('Unit: updateScore write failure', $scanner->updateScore('104', 'x', null)['success'] === false); $this->test('Unit: updatePieces write failure', $scanner->updatePieces('104', [['number' => 1, 'name' => 'a']])['success'] === false); @chmod($base . '104/score.ini', 0644); // updatePieces branches $this->test('Unit: updatePieces unknown', $scanner->updatePieces('999', [])['success'] === false); $this->test('Unit: updatePieces invalid number', $scanner->updatePieces('100', [['number' => 'x', 'name' => 'a']])['success'] === false); $this->test('Unit: updatePieces ok', $scanner->updatePieces('100', [['number' => 1, 'name' => 'A']])['success'] === true); $this->test('Unit: updatePieces parse failure', $scanner->updatePieces('002', [])['success'] === false); // getScoreInfo malformed / manual parse $broken = $read->invoke($scanner, '002'); $this->test('Unit: getScoreInfo manual parse fallback', is_array($broken) || $broken === null); $this->test('Unit: getScoreInfo missing ini returns null', $read->invoke($scanner, '003') === null); $manual = $read->invoke($scanner, '002'); $this->test('Unit: getScoreInfo manual name+compositor', is_array($manual) && ($manual['compositor'] ?? '') === 'Someone'); $this->test('Unit: getScoreInfo manual nothing returns null', $read->invoke($scanner, '008') === null); // uploadPdf branches (copy path, no uploaded flag) $file = ['tmp_name' => $base . '001/1/cla/1/clarinette-sib-1.pdf', 'name' => 'x.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 100]; $this->test('Unit: upload invalid score id', $scanner->uploadPdf('../x', $file, '1', 'dir', '1')['success'] === false); $this->test('Unit: upload unknown score', $scanner->uploadPdf('999', $file, '1', 'dir', '1')['success'] === false); $this->test('Unit: upload invalid instrument', $scanner->uploadPdf('100', $file, '1', '..', '1')['success'] === false); $this->test('Unit: upload invalid extension', $scanner->uploadPdf('100', ['tmp_name' => $base . '001/1/cla/1/readme.txt', 'name' => 'r.txt', 'error' => UPLOAD_ERR_OK, 'size' => 1], '1', 'dir', '1')['success'] === false); $this->test('Unit: upload bad header', $scanner->uploadPdf('100', ['tmp_name' => $base . '001/1/cla/1/readme.txt', 'name' => 'r.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 1], '1', 'dir', '1')['success'] === false); $weird = $base . '001/1/cla/1/weird.txt'; $this->test('Unit: upload pdf with wrong extension', $scanner->uploadPdf('100', ['tmp_name' => $weird, 'name' => 'weird.txt', 'error' => UPLOAD_ERR_OK, 'size' => filesize($weird)], '1', 'dir', '1')['success'] === false); $big = ['tmp_name' => $base . '001/1/cla/1/clarinette-sib-1.pdf', 'name' => 'big.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 999999999]; $this->test('Unit: upload too large', $scanner->uploadPdf('100', $big, '1', 'dir', '1')['success'] === false); $okUpload = $scanner->uploadPdf('100', $file, '1', 'cla', '1', 'sib', '', 'solo', '1'); $this->test('Unit: upload ok (copy)', $okUpload['success'] === true); $this->test('Unit: upload invalid version', $scanner->uploadPdf('100', $file, '1', 'dir', '..')['success'] === false); $this->test('Unit: upload invalid part', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '', '', '..')['success'] === false); $this->test('Unit: upload invalid key', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '..')['success'] === false); $this->test('Unit: upload invalid clef', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '..')['success'] === false); $this->test('Unit: upload invalid variant', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '', '..')['success'] === false); $this->test('Unit: upload no file', $scanner->uploadPdf('100', [], '1', 'dir', '1')['success'] === false); $this->test('Unit: upload error code', $scanner->uploadPdf('100', ['tmp_name' => $file['tmp_name'], 'name' => 'v.pdf', 'error' => UPLOAD_ERR_PARTIAL, 'size' => 1], '1', 'dir', '1')['success'] === false); $this->test('Unit: upload with clef ok', $scanner->uploadPdf('100', $file, '1', 'flu', '1', 'ut', 'clesol')['success'] === true); $wmPdf = $base . 'wm_src.pdf'; $this->makePdf($wmPdf, 2); $wmFile = ['tmp_name' => $wmPdf, 'name' => 'w.pdf', 'error' => UPLOAD_ERR_OK, 'size' => filesize($wmPdf)]; $wmUp = $scanner->uploadPdf('100', $wmFile, '1', 'cor', '1', '', '', '', '1', true, 'left'); $this->test('Unit: uploadPdf watermark left ok', $wmUp['success'] === true); file_put_contents($base . 'bad.pdf', '%PDF not a real pdf document'); $badWm = ['tmp_name' => $base . 'bad.pdf', 'name' => 'bad.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 26]; $this->test('Unit: uploadPdf watermark failure', $scanner->uploadPdf('100', $badWm, '1', 'dir', '1', '', '', '', '1', true, 'left')['success'] === false); @chmod($base . '100/1', 0555); $this->test('Unit: upload mkdir failure', $scanner->uploadPdf('100', $file, '1', 'xx', '1')['success'] === false); @chmod($base . '100/1', 0755); @chmod($base . '100/1/cor/1', 0555); $this->test('Unit: uploadPdf watermark copy failure', $scanner->uploadPdf('100', $wmFile, '1', 'cor', '1', '', '', '', '1', true, 'left')['success'] === false); @chmod($base . '100/1/cor/1', 0755); @chmod($base . '100/1/flu/1', 0555); $this->test('Unit: upload copy failure', $scanner->uploadPdf('100', $file, '1', 'flu', '1')['success'] === false); @chmod($base . '100/1/flu/1', 0755); @mkdir($base . '100/1/dir/1/direction-1.pdf', 0777, true); $this->test('Unit: uploadPdf rename failure', $scanner->uploadPdf('100', $wmFile, '1', 'dir', '1', '', '', '', '1', true, 'left')['success'] === false); @rmdir($base . '100/1/dir/1/direction-1.pdf'); $this->test('Unit: getScoreFiles unknown', $scanner->getScoreFiles('999')['success'] === false); $this->test('Unit: getScoreFiles ok', $scanner->getScoreFiles('100')['success'] === true); // deleteScoreFile branches $this->test('Unit: deleteFile unknown score', $scanner->deleteScoreFile('999', 'a.pdf')['success'] === false); $this->test('Unit: deleteFile traversal', $scanner->deleteScoreFile('100', '../../etc/passwd')['success'] === false); $this->test('Unit: deleteFile non-pdf', $scanner->deleteScoreFile('100', 'score.ini')['success'] === false); $this->test('Unit: deleteFile missing', $scanner->deleteScoreFile('100', '1/cla/1/none.pdf')['success'] === false); $rel = '1/cla/1/' . basename($okUpload['path']); $delFile = $scanner->deleteScoreFile('100', $rel); $this->test('Unit: deleteFile ok', $delFile['success'] === true); @symlink('/etc/hostname', $base . '100/evil.pdf'); $this->test('Unit: deleteFile escaped symlink', $scanner->deleteScoreFile('100', 'evil.pdf')['success'] === false); $this->test('Unit: deleteFile null byte', $scanner->deleteScoreFile('100', "bad\0.pdf")['success'] === false); @mkdir($base . '100/1/cla/1/subdir', 0777, true); $this->test('Unit: deleteFile directory', $scanner->deleteScoreFile('100', '1/cla/1/subdir')['success'] === false); @chmod($base . '100/1/flu/1', 0555); $this->test('Unit: deleteFile unlink failure', $scanner->deleteScoreFile('100', '1/flu/1/flute-ut-clesol-1.pdf')['success'] === false); @chmod($base . '100/1/flu/1', 0755); // deleteScore + deleteDirectory $this->test('Unit: deleteScore unknown', $scanner->deleteScore('999')['success'] === false); $this->test('Unit: deleteScore ok', $scanner->deleteScore('100')['success'] === true); // addWatermark multi-page (via reflection) $multi = $base . 'wm_multi.pdf'; $this->makePdf($multi, 2); $wm = $ref->getMethod('addWatermark'); $wm->setAccessible(true); $this->test('Unit: addWatermark multi-page returns true', $wm->invoke($scanner, $multi, '102', 'right') === true); $wmBad = $base . 'wm_bad.pdf'; file_put_contents($wmBad, 'not a pdf'); $this->test('Unit: addWatermark invalid returns false', $wm->invoke($scanner, $wmBad, '102', 'left') === false); // Auth branches $authOk = new Auth(); $login = $authOk->login('admin', 'password'); $this->test('Unit: auth login ok', !empty($login['token'])); $this->test('Unit: auth login wrong password', $authOk->login('admin', 'nope')['success'] === false); $this->test('Unit: auth login unknown user', $authOk->login('ghost', 'x')['success'] === false); $token = $login['token']; $this->test('Unit: verifyToken ok', is_array($authOk->verifyToken($token))); $this->test('Unit: verifyToken garbage', $authOk->verifyToken('a.b.c') === null); $this->test('Unit: verifyToken wrong parts', $authOk->verifyToken('onlyone') === null); $parts = explode('.', $token); $this->test('Unit: verifyToken bad signature', $authOk->verifyToken($parts[0] . '.' . $parts[1] . '.AAAA') === null); // alg=none header $algNone = rtrim(strtr(base64_encode(json_encode(['alg' => 'none', 'typ' => 'JWT'])), '+/', '-_'), '='); $this->test('Unit: verifyToken rejects alg none', $authOk->verifyToken($algNone . '.' . $parts[1] . '.' . $parts[2]) === null); // missing exp / expired payload signed with the real secret $secret = getenv('JWT_SECRET') ?: ($_ENV['JWT_SECRET'] ?? ''); $mk = function ($payload) use ($secret) { $h = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '='); $p = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); $s = rtrim(strtr(base64_encode(hash_hmac('sha256', "$h.$p", $secret, true)), '+/', '-_'), '='); return "$h.$p.$s"; }; $this->test('Unit: verifyToken missing exp', $authOk->verifyToken($mk(['username' => 'a'])) === null); $this->test('Unit: verifyToken expired', $authOk->verifyToken($mk(['username' => 'a', 'exp' => time() - 10])) === null); $this->test('Unit: verifyToken non-array payload', $authOk->verifyToken($mk('scalar')) === null); // missing users file $authNoUsers = new Auth($base . 'no_such_users.json'); $this->test('Unit: auth missing users file', $authNoUsers->login('x', 'y')['success'] === false); // missing secret $savedSecret = getenv('JWT_SECRET'); putenv('JWT_SECRET='); unset($_ENV['JWT_SECRET']); try { new Auth(); $this->test('Unit: auth throws without secret', false); } catch (Throwable $e) { $this->test('Unit: auth throws without secret', true); } if ($savedSecret !== false) { putenv('JWT_SECRET=' . $savedSecret); $_ENV['JWT_SECRET'] = $savedSecret; } $this->rrmdir($base); } public function testGeneralRateLimit() { $this->section("Security - General Rate Limit"); array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); $got429 = false; for ($i = 0; $i < 520; $i++) { $r = $this->request('GET', 'does/not/exist', null, false); if ($r['code'] === 429) { $got429 = true; break; } } $this->test('General rate limit returns 429', $got429); array_map('unlink', glob(sys_get_temp_dir() . '/rate_*')); } 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(); // Extended functional + unit coverage $tests->testMoreEndpoints(); $tests->testGeneralRateLimit(); $tests->testUnits(); // Summary $tests->summary();