Files
xamxam/tests/phpunit/ThesisCreateValidationTest.php
Pontoporeia 622aac5e58 fix(phpstan): resolve 6 pre-existing type errors + 2 cs-fixer nits
- AdminAuth: drop dead password_hash() === false check (PHP 8 returns string)
- ExportController: remove redundant !== null after isset() on duration fields
- SystemController: $def['json'] is always present, drop ?? false
- TfeController: simplify always-true type guard when sorting TFE files
- validate-file-fragment: AV 5GB override condition was always true, apply unconditionally
- tests: single-quote string, sort use statements (php-cs-fixer)
2026-09-18 16:41:26 +02:00

431 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use PHPUnit\Framework\TestCase;
/**
* ThesisCreateValidationTest — Tests for ThesisCreateController::validateAndSanitise()
* and related validation logic.
*
* validateAndSanitise() is private; we test via a thin subclass or by driving
* the submit() method with controlled inputs. For pure validation tests,
* we use reflection.
*/
class ThesisCreateValidationTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
TestDatabase::resetData();
$this->pdo = TestDatabase::getPDO();
}
/**
* Invoke the private validateAndSanitise() method via reflection.
*/
private function validate(array $post, bool $adminMode = false): array
{
$ref = new ReflectionMethod(ThesisCreateController::class, 'validateAndSanitise');
$db = TestDatabase::getInstance();
$ctrl = new ThesisCreateController($db);
return $ref->invoke($ctrl, $post, $adminMode);
}
/**
* Build minimal valid POST data for a submission.
*/
private function validPost(): array
{
return [
'auteurice' => 'John Doe',
'année' => '2024',
'orientation' => '1',
'ap' => '1',
'finality' => '1',
'titre' => 'My Thesis Title',
'synopsis' => 'A compelling synopsis.',
'jury_promoteur' => ['Promoteur One'],
'jury_lecteur_interne' => ['Lecteur Interne'],
'jury_lecteur_externe' => ['Lecteur Externe'],
'tag' => ['art', 'design', 'research'],
'languages' => ['1'],
'formats' => ['2'],
'access_type_id' => '1',
'license_id' => '1',
'objet' => 'tfe',
];
}
// ── Valid submission ─────────────────────────────────────────────────────
public function testValidSubmissionReturnsCleanedData(): void
{
$data = $this->validate($this->validPost());
$this->assertSame('My Thesis Title', $data['titre']);
$this->assertSame(2024, $data['annee']);
$this->assertSame('John Doe', $data['authorNames'][0]);
$this->assertNotEmpty($data['juryMembers']);
$this->assertCount(3, $data['keywords']);
}
// ── Missing required fields ──────────────────────────────────────────────
public function testMissingTitleThrowsException(): void
{
$post = $this->validPost();
$post['titre'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Titre du TFE');
$this->validate($post);
}
public function testMissingAuthorsThrowsException(): void
{
$post = $this->validPost();
$post['auteurice'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Auteur');
$this->validate($post);
}
public function testMissingSynopsisThrowsException(): void
{
$post = $this->validPost();
$post['synopsis'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Synopsis');
$this->validate($post);
}
public function testMissingOrientationInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['orientation'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('orientation');
$this->validate($post);
}
public function testMissingAPProgramInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['ap'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Atelier Pratique');
$this->validate($post);
}
public function testMissingFinalityInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['finality'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('finalité');
$this->validate($post);
}
// ── Invalid year ─────────────────────────────────────────────────────────
public function testInvalidYearFormatRejected(): void
{
$post = $this->validPost();
$post['année'] = 'not-a-year';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Année invalide');
$this->validate($post);
}
public function testYearZeroRejected(): void
{
$post = $this->validPost();
$post['année'] = '0';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Année invalide');
$this->validate($post);
}
public function testYearBefore2000Rejected(): void
{
$post = $this->validPost();
$post['année'] = '1999';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Année invalide');
$this->validate($post);
}
public function testFarFutureYearRejected(): void
{
$post = $this->validPost();
$post['année'] = (string)((int)date('Y') + 5);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Année invalide');
$this->validate($post);
}
public function testCurrentYearAccepted(): void
{
$post = $this->validPost();
$post['année'] = (string)(int)date('Y');
$data = $this->validate($post);
$this->assertSame((int)date('Y'), $data['annee']);
}
// ── Malformed URL ────────────────────────────────────────────────────────
public function testMalformedUrlRejected(): void
{
$post = $this->validPost();
$post['lien'] = 'not-a-valid-url';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Lien URL invalide');
$this->validate($post);
}
public function testValidUrlAccepted(): void
{
$post = $this->validPost();
$post['lien'] = 'https://example.com';
$data = $this->validate($post);
$this->assertSame('https://example.com', $data['lien']);
}
// ── Tag deduplication ────────────────────────────────────────────────────
public function testDuplicateTagsAreDeduplicated(): void
{
$post = $this->validPost();
$post['tag'] = ['art', 'Art', 'ART', 'design', 'research', 'philosophy'];
$data = $this->validate($post);
// Tags are lowercased and deduplicated: art×3 + design + research + philosophy = 4
$this->assertCount(4, $data['keywords']);
$expected = ['art', 'design', 'philosophy', 'research'];
sort($data['keywords']);
$this->assertSame($expected, $data['keywords']);
}
public function testMaxTenKeywordsEnforced(): void
{
$post = $this->validPost();
$post['tag'] = range(1, 12);
$data = $this->validate($post);
$this->assertCount(10, $data['keywords']);
}
// ── XSS escaping ─────────────────────────────────────────────────────────
public function testXssPayloadStrippedFromTitle(): void
{
$post = $this->validPost();
$post['titre'] = '<script>alert("xss")</script>Clean Title';
$data = $this->validate($post);
$this->assertStringNotContainsString('<script>', $data['titre']);
$this->assertStringContainsString('Clean Title', $data['titre']);
}
public function testHtmlInSynopsisStripped(): void
{
$post = $this->validPost();
$post['synopsis'] = '<b>Bold</b> synopsis <img src=x onerror=alert(1)>';
$data = $this->validate($post);
$this->assertStringNotContainsString('<b>', $data['synopsis']);
$this->assertStringNotContainsString('<img', $data['synopsis']);
$this->assertStringContainsString('Bold synopsis', $data['synopsis']);
}
// ── Whitespace normalisation (safe, unambiguous subset only) ────────────────
// Single newlines inside a paragraph are deliberately LEFT untouched: they can
// be a word boundary (→ space) or a word split across a line ("dyna\nmiques"
// → "dynamiques") and are indistinguishable, so we never guess and risk
// corrupting valid input (e.g. "se poursuivant\nsur" → "poursuivantsur").
public function testSingleIntraParagraphNewlineIsLeftUntouched(): void
{
// A mid-word line break is ambiguous (could be a real split or a wrap),
// so we do not auto-alter it — the author is responsible for cleaning it.
$post = $this->validPost();
$post['synopsis'] = "…les dyn\namiques complexes…";
$data = $this->validate($post);
$this->assertSame("…les dyn\namiques complexes…", $data['synopsis']);
}
public function testWordWrapSingleNewlineNotMerged(): void
{
// We must NOT merge two real words across a line break ("poursuivantsur").
$post = $this->validPost();
$post['synopsis'] = "se poursuivant\nsur une seconde ligne";
$data = $this->validate($post);
$this->assertSame(
"se poursuivant\nsur une seconde ligne",
$data['synopsis']
);
}
public function testLegitimateParagraphBreakPreserved(): void
{
$post = $this->validPost();
$post['synopsis'] = "Premier paragraphe\n\nSecond paragraphe";
$data = $this->validate($post);
$this->assertSame(
"Premier paragraphe\n\nSecond paragraphe",
$data['synopsis']
);
}
public function testMultipleBlankLinesCollapseToSingleParagraphBreak(): void
{
$post = $this->validPost();
$post['synopsis'] = "A\n\n\n\nB";
$data = $this->validate($post);
$this->assertSame("A\n\nB", $data['synopsis']);
}
public function testMixedWindowsAndUnixLineEndingsNormalised(): void
{
$post = $this->validPost();
$post['synopsis'] = "une pause\r\n\r\ner une future";
$data = $this->validate($post);
// \r\n and \r line-endings all collapse to \n, and blank lines mark
// a single paragraph separator.
$this->assertSame("une pause\n\ner une future", $data['synopsis']);
}
public function testConsecutiveSpacesAndTabsCollapse(): void
{
$post = $this->validPost();
$post['synopsis'] = "Très espacé\t\ttab";
$data = $this->validate($post);
$this->assertSame('Très espacé tab', $data['synopsis']);
}
public function testNonBreakingSpacesNormalised(): void
{
$post = $this->validPost();
// Word pasted NBSP (U+00A0) between words must become a regular space.
$post['synopsis'] = "Premier\u{00A0}mot second mot";
$data = $this->validate($post);
$this->assertSame('Premier mot second mot', $data['synopsis']);
}
public function testSynopsisSingleLineFieldTrimmed(): void
{
$post = $this->validPost();
$post['synopsis'] = ' texte qui flotte ';
$data = $this->validate($post);
$this->assertSame('texte qui flotte', $data['synopsis']);
}
// ── Author processing ────────────────────────────────────────────────────
public function testMultipleAuthorsAreSorted(): void
{
$post = $this->validPost();
$post['auteurice'] = 'Zoe, Alice, Bob';
$data = $this->validate($post);
$this->assertSame(['Alice', 'Bob', 'Zoe'], $data['authorNames']);
}
// ── Jury validation ──────────────────────────────────────────────────────
public function testMissingPromoteurInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['jury_promoteur'] = [];
$this->expectException(Exception::class);
$this->expectExceptionMessage('promoteur');
$this->validate($post);
}
public function testMissingLecteurInterneInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['jury_lecteur_interne'] = [];
$this->expectException(Exception::class);
$this->expectExceptionMessage('lecteur·ice interne');
$this->validate($post);
}
// ── Language validation ──────────────────────────────────────────────────
public function testMissingLanguagesInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
unset($post['languages']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('langue');
$this->validate($post);
}
// ── Format validation ────────────────────────────────────────────────────
public function testMissingFormatsInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
unset($post['formats']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('format');
$this->validate($post);
}
// ── License validation ───────────────────────────────────────────────────
public function testMissingLicenseWithLibreAccessThrowsException(): void
{
$post = $this->validPost();
$post['access_type_id'] = '1';
$post['license_id'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('licence');
$this->validate($post);
}
}