[FIX] Uge session of debugging and code coverage.
This commit is contained in:
+489
-69
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user