[FIX] Uge session of debugging and code coverage.

This commit is contained in:
NADAL Jean-Baptiste
2026-09-14 17:22:18 +02:00
parent e79c865f8c
commit a38fc0bfa9
35 changed files with 2315 additions and 1092 deletions
-2
View File
@@ -1,2 +0,0 @@
JWT_SECRET=6jh/MWqVplwXQKsiwlKahE19TSavfR1dNCawsQFixus=
SCORES_PATH=/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/
+18
View File
@@ -2,6 +2,24 @@
API REST pour la gestion des partitions de l'Harmonie de Montpellier-Jacou.
## Installation
Prérequis : **PHP 8.5** avec les extensions `gd`, `zlib`, `curl`.
```bash
cd api
php composer.phar install # vendor/ est déjà présent dans le dépôt
cp .env.example .env # puis définir JWT_SECRET
```
Dépendances (`composer.json`) :
| Package | Rôle |
|---|---|
| `setasign/fpdi` | import de pages PDF (watermark) |
| `setasign/fpdf` | génération PDF de base |
| `tecnickcom/tcpdf` | boîte à outils PDF |
## Base URL
```
+25
View File
@@ -0,0 +1,25 @@
<?php
// Dev-only: prepend file that enables Xdebug code coverage on the PHP
// built-in server. Loaded via -d auto_prepend_file=... during coverage runs.
// Not shipped in the deploy archive (see deploy/deploy.sh).
if (!function_exists('xdebug_start_code_coverage')) {
return;
}
xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [
realpath(__DIR__ . '/index.php'),
realpath(__DIR__ . '/router.php'),
realpath(__DIR__ . '/lib'),
]);
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
register_shutdown_function(function () {
$cov = xdebug_get_code_coverage();
$dir = sys_get_temp_dir() . '/ohmj_cov';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents($dir . '/cov_' . getmypid() . '_' . uniqid('', true) . '.ser', serialize($cov));
});
+64
View File
@@ -0,0 +1,64 @@
<?php
// Dev-only: merges the Xdebug dumps produced by coverage-prepend.php and
// prints per-file line coverage. Usage (from the repo root):
//
// SHOW_MISSING=1 php api/coverage-report.php \
// api/index.php api/router.php api/lib/Auth.php api/lib/ScoreScanner.php
$dir = sys_get_temp_dir() . '/ohmj_cov';
$files = glob($dir . '/cov_*.ser') ?: [];
$merged = [];
foreach ($files as $f) {
$data = @unserialize(file_get_contents($f));
if (!is_array($data)) {
continue;
}
foreach ($data as $file => $lines) {
foreach ($lines as $line => $status) {
if (!isset($merged[$file][$line]) || $status === 1) {
$merged[$file][$line] = ($status === 1 || ($merged[$file][$line] ?? -1) === 1) ? 1 : $status;
}
}
}
}
$targets = array_slice($argv, 1);
if (!$targets) {
$targets = array_keys($merged);
}
$grandTotal = 0;
$grandHit = 0;
foreach ($targets as $target) {
$target = realpath($target) ?: $target;
$lines = $merged[$target] ?? [];
$exec = [];
$hit = [];
foreach ($lines as $line => $status) {
if ($status === -2) {
continue; // dead code
}
$exec[$line] = true;
if ($status === 1) {
$hit[$line] = true;
}
}
$total = count($exec);
$covered = count($hit);
$grandTotal += $total;
$grandHit += $covered;
$pct = $total > 0 ? round($covered * 100 / $total, 1) : 0.0;
$missing = array_diff(array_keys($exec), array_keys($hit));
sort($missing);
printf("%6.1f%% %3d/%-3d %s\n", $pct, $covered, $total, str_replace(getcwd() . '/', '', $target));
if ($missing && getenv('SHOW_MISSING')) {
echo ' manquantes: ' . implode(', ', $missing) . "\n";
}
}
if ($grandTotal > 0) {
printf("\nTOTAL: %d/%d (%.1f%%)\n", $grandHit, $grandTotal, $grandHit * 100 / $grandTotal);
}
echo 'dumps: ' . count($files) . "\n";
+50 -68
View File
@@ -17,10 +17,6 @@ if (file_exists(__DIR__ . '/.env')) {
}
}
// Increase upload limits for this script
ini_set('upload_max_filesize', '64M');
ini_set('post_max_size', '64M');
// Security headers
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
@@ -29,17 +25,14 @@ header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
// CORS - Restrict to allowed origins
// CORS - only allowlisted origins receive the header (no wildcard fallback)
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Always send CORS headers for preflight requests
if (in_array($origin, $allowedOrigins)) {
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
} else {
// For preflight without matching origin, still allow (for debugging)
header("Access-Control-Allow-Origin: *");
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
@@ -49,31 +42,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// Rate limiting function
// Normalize the path once (strip an optional /api prefix)
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$path = preg_replace('#^api/#', '', $path);
// Rate limiting (flock-protected to avoid read-modify-write races)
function checkRateLimit(string $key, int $maxRequests = 100, int $windowSeconds = 60): bool {
$rateFile = sys_get_temp_dir() . '/rate_' . md5($key) . '.json';
$now = time();
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
if (file_exists($rateFile)) {
$content = file_get_contents($rateFile);
$data = json_decode($content, true) ?? $data;
$fp = @fopen($rateFile, 'c+');
if ($fp === false) {
return true; // fail open if the temp dir is not writable
}
// Reset if window expired
if ($now > $data['reset']) {
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return true;
}
$content = stream_get_contents($fp);
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['count'], $data['reset']) || $now > $data['reset']) {
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
}
$data['count']++;
file_put_contents($rateFile, json_encode($data));
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $data['count'] <= $maxRequests;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Check rate limit for login endpoint (5 attempts per minute)
if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/')) && $_SERVER['REQUEST_METHOD'] === 'POST') {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if ($path === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!checkRateLimit('login_' . $ip, 5, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests. Please try again later.']);
@@ -82,7 +89,6 @@ if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH
}
// Check general rate limit (500 requests per minute per IP - increased for testing)
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (!checkRateLimit('general_' . $ip, 500, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
@@ -99,30 +105,19 @@ if ($scoresPath === false || $scoresPath === '') {
}
$scanner = new ScoreScanner($scoresPath);
// Get Authorization header
// Get Authorization header (PHP exposes it as HTTP_AUTHORIZATION)
$token = null;
$headers = getallheaders();
if (isset($headers['Authorization'])) {
$authHeader = $headers['Authorization'];
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
$token = $matches[1];
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
$token = trim($matches[1]);
}
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path = trim($uri, '/');
// Remove 'api/' prefix if present
$path = preg_replace('#^api/#', '', $path);
// Debug: log the path
// file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " PATH: $path METHOD: $method\n", FILE_APPEND);
// GET /download/:path - Download PDF (BEFORE auth check)
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
$filePath = urldecode($matches[1]);
$filePath = rawurldecode($matches[1]);
// Security: Check for null bytes (must use double quotes for escape sequence)
if (strpos($filePath, "\x00") !== false) {
@@ -134,11 +129,10 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
// Security: Prevent directory traversal
$filePath = str_replace(['../', '..\\', '..'], '', $filePath);
// Check token from header or query parameter
$downloadToken = $_GET['token'] ?? $token;
$downloadToken = urldecode($downloadToken ?? '');
// Security: token is read from the Authorization header only
$downloadToken = $token ?? '';
if ($downloadToken === null || $downloadToken === '') {
if ($downloadToken === '') {
http_response_code(401);
echo json_encode(['error' => 'Authorization required']);
exit;
@@ -158,7 +152,12 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
$realBasePath = realpath($basePath);
$realFilePath = realpath($fullPath);
if ($realFilePath === false || strpos($realFilePath, $realBasePath) !== 0) {
if ($realFilePath === false) {
http_response_code(404);
echo json_encode(['error' => 'File not found']);
exit;
}
if ($realBasePath === false || strpos($realFilePath, $realBasePath . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
echo json_encode(['error' => 'Access denied']);
exit;
@@ -171,12 +170,6 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
exit;
}
if (!file_exists($realFilePath) || !is_file($realFilePath)) {
http_response_code(404);
echo json_encode(['error' => 'File not found']);
exit;
}
header('Content-Type: application/pdf');
header('Content-Length: ' . filesize($realFilePath));
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
@@ -294,16 +287,9 @@ if ($path === 'admin/scores' && $method === 'POST') {
if ($num > $maxId) $maxId = $num;
}
// Skip existing IDs
$scoresPath = getenv('SCORES_PATH') ?: __DIR__ . '/../legacy/Scores/';
$id = strval($maxId + 1);
while (is_dir($scoresPath . $id)) {
$id = strval(intval($id) + 1);
}
// Pad with zeros to 3 digits if needed
if (strlen($id) < 3) {
$id = str_pad($id, 3, '0', STR_PAD_LEFT);
}
// Next score id, zero-padded to 3 digits.
// A concurrent collision is rejected by createScore (already exists).
$id = str_pad((string) ($maxId + 1), 3, '0', STR_PAD_LEFT);
}
if (empty($name)) {
@@ -330,7 +316,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT')
$name = $input['name'] ?? null;
$compositor = $input['compositor'] ?? null;
$ressource = $input['ressource'] ?? '';
$ressource = $input['ressource'] ?? null;
$result = $scanner->updateScore($scoreId, $name, $compositor, $ressource);
@@ -370,11 +356,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
if ($result['success']) {
echo json_encode(['success' => true]);
} else {
if (strpos($result['error'], 'not found') !== false) {
http_response_code(404);
} else {
http_response_code(400);
}
http_response_code(404);
echo json_encode(['error' => $result['error']]);
}
exit;
+50 -47
View File
@@ -7,7 +7,7 @@ class Auth {
private string $usersFile;
private string $jwtSecret;
public function __construct(string $usersFile = null) {
public function __construct(?string $usersFile = null) {
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
// Load JWT secret from environment variable
@@ -19,25 +19,34 @@ class Auth {
public function login(string $username, string $password): array {
$users = $this->loadUsers();
$user = null;
foreach ($users['users'] as $user) {
if ($user['username'] === $username) {
if (password_verify($password, $user['password'])) {
$token = $this->generateToken($user);
return [
'success' => true,
'token' => $token,
'user' => [
'username' => $user['username'],
'role' => $user['role']
]
];
}
return ['success' => false, 'error' => 'Invalid password'];
foreach ($users['users'] as $candidate) {
if (hash_equals($candidate['username'], $username)) {
$user = $candidate;
break;
}
}
return ['success' => false, 'error' => 'User not found'];
// Always run a password check to avoid a timing oracle on user existence
if ($user === null) {
password_verify($password, '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG');
return ['success' => false, 'error' => 'Invalid credentials'];
}
if (!password_verify($password, $user['password'])) {
return ['success' => false, 'error' => 'Invalid credentials'];
}
$token = $this->generateToken($user);
return [
'success' => true,
'token' => $token,
'user' => [
'username' => $user['username'],
'role' => $user['role']
]
];
}
public function verifyToken(string $token): ?array {
@@ -49,8 +58,14 @@ class Auth {
list($header, $payload, $signature) = $parts;
// Verify signature
$expectedSignature = base64_encode(
// Verify algorithm header before trusting anything
$headerData = json_decode(self::base64UrlDecode($header), true);
if (!is_array($headerData) || ($headerData['alg'] ?? '') !== self::JWT_ALGO) {
return null;
}
// Verify signature (constant-time)
$expectedSignature = self::base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
);
@@ -59,54 +74,46 @@ class Auth {
}
// Decode payload
$payloadData = json_decode(base64_decode($payload), true);
$payloadData = json_decode(self::base64UrlDecode($payload), true);
// Check expiry
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) {
if (!is_array($payloadData)) {
return null;
}
// Expiry is mandatory
if (!isset($payloadData['exp']) || !is_numeric($payloadData['exp']) || (int) $payloadData['exp'] < time()) {
return null;
}
return $payloadData;
}
public function requireAuth(string $token): array {
$payload = $this->verifyToken($token);
if ($payload === null) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
return $payload;
private static function base64UrlEncode(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
public function requireAdmin(string $token): array {
$payload = $this->requireAuth($token);
if ($payload['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
private static function base64UrlDecode(string $data): string {
$remainder = strlen($data) % 4;
if ($remainder !== 0) {
$data .= str_repeat('=', 4 - $remainder);
}
return $payload;
return base64_decode(strtr($data, '-_', '+/'));
}
private function generateToken(array $user): string {
$header = base64_encode(json_encode([
$header = self::base64UrlEncode(json_encode([
'alg' => self::JWT_ALGO,
'typ' => 'JWT'
]));
$payload = base64_encode(json_encode([
$payload = self::base64UrlEncode(json_encode([
'username' => $user['username'],
'role' => $user['role'],
'iat' => time(),
'exp' => time() + self::JWT_EXPIRY
]));
$signature = base64_encode(
$signature = self::base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
);
@@ -121,8 +128,4 @@ class Auth {
$content = file_get_contents($this->usersFile);
return json_decode($content, true) ?? ['users' => []];
}
public function hashPassword(string $password): string {
return password_hash($password, PASSWORD_BCRYPT);
}
}
+150 -60
View File
@@ -406,6 +406,11 @@ class ScoreScanner {
}
public function createScore(string $id, string $name, string $compositor, array $pieces = []): array {
// Security: the id becomes a directory name, allow digits only
if (!$this->isValidSegment($id, '/^\d{1,6}$/')) {
return ['success' => false, 'error' => 'Invalid score id'];
}
$scoreDir = $this->scoresPath . $id;
if (is_dir($scoreDir)) {
@@ -425,12 +430,17 @@ class ScoreScanner {
// Add piece names if provided
foreach ($pieces as $piece) {
$num = $piece['number'];
$pieceName = $this->sanitizeIniValue($piece['name']);
$num = $piece['number'] ?? $piece['id'] ?? '';
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
$this->deleteDirectory($scoreDir);
return ['success' => false, 'error' => 'Invalid piece number'];
}
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
$iniContent .= "{$num} = $pieceName\n";
}
if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) {
$this->deleteDirectory($scoreDir);
return ['success' => false, 'error' => 'Failed to create score.ini'];
}
@@ -445,11 +455,15 @@ class ScoreScanner {
}
private function sanitizeIniValue(string $value): string {
// Remove newlines and carriage returns to prevent INI injection
return str_replace(["\n", "\r", "\x00"], '', $value);
// Remove control characters that would break/inject INI lines
return trim(str_replace(["\n", "\r", "\x00", "\x1a"], '', $value));
}
private function isValidSegment(string $value, string $pattern): bool {
return $value !== '' && $value !== '.' && $value !== '..' && (bool) preg_match($pattern, $value);
}
public function updateScore(string $scoreId, ?string $name, ?string $compositor): array {
public function updateScore(string $scoreId, ?string $name, ?string $compositor, ?string $ressource = null): array {
$scoreDir = $this->scoresPath . $scoreId;
$iniFile = $scoreDir . '/score.ini';
@@ -462,23 +476,39 @@ class ScoreScanner {
return ['success' => false, 'error' => 'Failed to parse score.ini'];
}
// Update values
$currentName = $ini['info']['name'] ?? '';
$currentCompositor = $ini['info']['compositor'] ?? '';
$currentRessource = $ini['info']['ressource'] ?? '';
// Update values (keep existing when the field is not provided)
if ($name !== null) {
$ini['info']['name'] = $name;
$currentName = $this->sanitizeIniValue($name);
}
if ($compositor !== null) {
$ini['info']['compositor'] = $compositor;
$currentCompositor = $this->sanitizeIniValue($compositor);
}
if ($ressource !== null) {
$ressource = trim($ressource);
if ($ressource !== '' && !preg_match('#^https?://#i', $ressource)) {
return ['success' => false, 'error' => 'Invalid ressource URL (http/https only)'];
}
$currentRessource = $this->sanitizeIniValue($ressource);
}
// Rebuild ini content
$content = "[info]\n";
$content .= "name = " . ($ini['info']['name'] ?? '') . "\n";
$content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n";
$content .= "[pieces]\n";
$content .= "name = $currentName\n";
$content .= "compositor = $currentCompositor\n";
if ($currentRessource !== '') {
$content .= "ressource = $currentRessource\n";
}
$content .= "\n[pieces]\n";
if (isset($ini['pieces'])) {
foreach ($ini['pieces'] as $key => $value) {
$content .= "$key = $value\n";
$safeKey = $this->sanitizeIniValue((string) $key);
$safeValue = $this->sanitizeIniValue((string) $value);
$content .= "$safeKey = $safeValue\n";
}
}
@@ -503,9 +533,9 @@ class ScoreScanner {
}
// Get existing info
$name = $ini['info']['name'] ?? '';
$compositor = $ini['info']['compositor'] ?? '';
$ressource = $ini['info']['ressource'] ?? '';
$name = $this->sanitizeIniValue($ini['info']['name'] ?? '');
$compositor = $this->sanitizeIniValue($ini['info']['compositor'] ?? '');
$ressource = $this->sanitizeIniValue($ini['info']['ressource'] ?? '');
// Rebuild ini content with new pieces
$content = "[info]\n";
@@ -518,8 +548,11 @@ class ScoreScanner {
$content .= "count = " . count($pieces) . "\n";
foreach ($pieces as $piece) {
$num = $piece['number'] ?? $piece['id'];
$pieceName = $piece['name'] ?? '';
$num = $piece['number'] ?? $piece['id'] ?? '';
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
return ['success' => false, 'error' => 'Invalid piece number'];
}
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
$content .= "$num = $pieceName\n";
}
@@ -562,6 +595,32 @@ class ScoreScanner {
}
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left'): array {
// Security: every input used to build a path or a filename is validated
if (!$this->isValidSegment($scoreId, '/^\d{1,6}$/')) {
return ['success' => false, 'error' => 'Invalid score id'];
}
if (!$this->isValidSegment($piece, '/^\d{1,3}$/')) {
return ['success' => false, 'error' => 'Invalid piece'];
}
if (!$this->isValidSegment($instrument, '/^[a-z]{2,4}$/')) {
return ['success' => false, 'error' => 'Invalid instrument'];
}
if (!$this->isValidSegment($version, '/^[A-Za-z0-9_-]{1,10}$/')) {
return ['success' => false, 'error' => 'Invalid version'];
}
if ($part !== '' && !$this->isValidSegment($part, '/^[A-Za-z0-9_+-]{1,20}$/')) {
return ['success' => false, 'error' => 'Invalid part'];
}
if ($key !== '' && !$this->isValidSegment($key, '/^[a-z]{2,8}$/')) {
return ['success' => false, 'error' => 'Invalid key'];
}
if ($clef !== '' && !$this->isValidSegment($clef, '/^[a-z]{2,8}$/')) {
return ['success' => false, 'error' => 'Invalid clef'];
}
if ($variant !== '' && !$this->isValidSegment($variant, '/^[a-z0-9_-]{1,20}$/')) {
return ['success' => false, 'error' => 'Invalid variant'];
}
$scoreDir = $this->scoresPath . $scoreId;
if (!is_dir($scoreDir)) {
@@ -578,15 +637,6 @@ class ScoreScanner {
return ['success' => false, 'error' => 'Upload error: ' . $file['error']];
}
// Security: Validate MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if ($mimeType !== 'application/pdf') {
return ['success' => false, 'error' => 'Invalid file type. Only PDF files are allowed'];
}
// Security: Validate extension
$originalExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($originalExtension !== 'pdf') {
@@ -647,22 +697,38 @@ class ScoreScanner {
$targetPath = $targetDir . '/' . $filename;
// Use copy for CLI testing, move_uploaded_file for real uploads
if (is_uploaded_file($file['tmp_name'])) {
$result = move_uploaded_file($file['tmp_name'], $targetPath);
if ($watermark) {
// Write to a temp file, watermark it, then move into place atomically
$tmpPath = $targetPath . '.tmp';
if (is_uploaded_file($file['tmp_name'])) {
$result = move_uploaded_file($file['tmp_name'], $tmpPath);
} else {
$result = copy($file['tmp_name'], $tmpPath);
}
if (!$result) {
return ['success' => false, 'error' => 'Failed to move uploaded file'];
}
if (!$this->addWatermark($tmpPath, $scoreId, $watermarkPosition)) {
@unlink($tmpPath);
return ['success' => false, 'error' => 'Failed to apply watermark'];
}
if (!rename($tmpPath, $targetPath)) {
@unlink($tmpPath);
return ['success' => false, 'error' => 'Failed to store watermarked file'];
}
} else {
$result = copy($file['tmp_name'], $targetPath);
// Use copy for CLI testing, move_uploaded_file for real uploads
if (is_uploaded_file($file['tmp_name'])) {
$result = move_uploaded_file($file['tmp_name'], $targetPath);
} else {
$result = copy($file['tmp_name'], $targetPath);
}
if (!$result) {
return ['success' => false, 'error' => 'Failed to move uploaded file'];
}
}
if ($result && $watermark) {
$this->addWatermark($targetPath, $scoreId, $watermarkPosition);
}
if ($result) {
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
}
return ['success' => false, 'error' => 'Failed to move uploaded file'];
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
}
public function getScoreFiles(string $scoreId): array {
@@ -709,22 +775,42 @@ class ScoreScanner {
}
public function deleteScoreFile(string $scoreId, string $filePath): array {
$scoreDir = $this->scoresPath . $scoreId;
$fullPath = $scoreDir . '/' . $filePath;
if (!file_exists($fullPath)) {
$scoreDir = realpath($this->scoresPath . $scoreId);
if ($scoreDir === false || !is_dir($scoreDir)) {
return ['success' => false, 'error' => 'Score not found'];
}
// Security: reject null bytes and resolve the real target
if (strpos($filePath, "\x00") !== false) {
return ['success' => false, 'error' => 'Invalid file path'];
}
$fullPath = realpath($scoreDir . '/' . $filePath);
if ($fullPath === false) {
return ['success' => false, 'error' => 'File not found'];
}
// Security: ensure the file stays inside the score directory
if (strpos($fullPath, $scoreDir . DIRECTORY_SEPARATOR) !== 0) {
return ['success' => false, 'error' => 'Access denied'];
}
// Security: only PDF files can be deleted, never score.ini
if (is_dir($fullPath)) {
return ['success' => false, 'error' => 'Cannot delete directory'];
}
if (strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)) !== 'pdf') {
return ['success' => false, 'error' => 'Only PDF files can be deleted'];
}
if (unlink($fullPath)) {
$this->cleanupEmptyDirectories($scoreDir, dirname($filePath));
return ['success' => true];
}
return ['success' => false, 'error' => 'Failed to delete file'];
}
@@ -750,26 +836,30 @@ class ScoreScanner {
require_once __DIR__ . '/../vendor/autoload.php';
$pdf = new \setasign\Fpdi\Fpdi();
$pdf->setSourceFile($pdfPath);
$templateId = $pdf->importPage(1);
$size = $pdf->getTemplateSize($templateId);
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
$pdf->useTemplate($templateId);
$pageCount = $pdf->setSourceFile($pdfPath);
$pdf->SetFont('helvetica', 'B', 20);
$pdf->SetTextColor(0, 0, 0);
if ($position === 'right') {
$pdf->SetXY($size['width'] - 45, 5);
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
} else {
$pdf->SetXY(10, 5);
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
// Apply the watermark on every page (do not truncate multi-page PDFs)
for ($i = 1; $i <= $pageCount; $i++) {
$templateId = $pdf->importPage($i);
$size = $pdf->getTemplateSize($templateId);
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
$pdf->useTemplate($templateId);
if ($position === 'right') {
$pdf->SetXY($size['width'] - 45, 5);
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
} else {
$pdf->SetXY(10, 5);
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
}
}
return $pdf->Output('F', $pdfPath);
$pdf->Output('F', $pdfPath);
return true;
} catch (\Exception $e) {
error_log('Watermark error: ' . $e->getMessage());
return false;
+3 -45
View File
@@ -1,48 +1,6 @@
<?php
// Load environment variables from .env file
$envFile = __DIR__ . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0) continue;
if (strpos($line, '=') === false) continue;
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (!empty($key)) {
putenv("$key=$value");
$_ENV[$key] = $value;
}
}
}
// CORS headers - MUST be sent before any other output
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
} else {
header("Access-Control-Allow-Origin: *");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
// Handle OPTIONS preflight immediately
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Router script for PHP built-in server
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Serve existing files directly
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
return false;
}
// Route everything through index.php
// Dev server router: every request goes through index.php (environment
// loading, CORS and OPTIONS handling all live there). No file is ever served
// statically, so .env, config/users.json and composer.phar stay unreachable.
require __DIR__ . '/index.php';
+489 -69
View File
@@ -175,13 +175,18 @@ class APITest {
public function testCreateScoreWithPieces() {
$this->section("Create Score with Pieces - Functional");
// Test 0: Try to create score with existing ID should fail
// 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' => '001'
'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');
@@ -300,60 +305,16 @@ class APITest {
// 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";
// 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;
}
$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);
$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", [
@@ -367,18 +328,16 @@ startxref
$this->test('Upload with watermark left returns 200', $result['code'] === 200);
// Get files to verify it was uploaded
// Get files to verify it was uploaded (recursive tree)
$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;
}
}
$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
@@ -396,13 +355,32 @@ startxref
// Clean up
@unlink($tmpFile);
// Clean up uploaded files
// Clean up all uploaded files (recursive) then the dedicated score
$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']);
$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']);
}
}
}
@@ -636,6 +614,443 @@ startxref
// ============ 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);
// 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";
@@ -680,5 +1095,10 @@ $tests->testFileUploadSecurity();
$tests->testInjectionAttacks();
$tests->testHttpsEnforcement();
// Extended functional + unit coverage
$tests->testMoreEndpoints();
$tests->testGeneralRateLimit();
$tests->testUnits();
// Summary
$tests->summary();