Tests für Upload und Ui, strengere CI-Prüfungen

- tests/UploadTest.php prüft den SVG-Filter (Skripte, Event-Handler,
  javascript:-Verweise) und Upload::isLocal gegen Pfad-Tricks
- tests/UiTest.php prüft Versionsstempel, Media-Pfade und die
  Branding-Overrides inklusive Abweisung ungültiger Farbwerte
- PHPStan analysiert jetzt auch includes/Ui.php und includes/Upload.php
- CI vergleicht die Sprachdateien (gleiche Schlüsselmenge) und prüft,
  dass jeder im Code verwendete Schlüssel existiert

Dabei aufgefallen und behoben: Upload.php rief __() direkt auf und wäre
außerhalb einer Seite mit geladener I18n mit einem Fatal Error
abgebrochen; jetzt gibt es einen Fallback auf die deutsche Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 06:52:22 +00:00
parent 36e06ac817
commit 0716311ff6
5 changed files with 220 additions and 10 deletions

93
tests/UiTest.php Normal file
View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Ui.php';
/**
* Sehr einfache Datenbank-Attrappe: liefert nur Einstellungen zurueck.
*/
class FakeSettings
{
/** @var array<string, string> */
private array $values;
/** @param array<string, string> $values */
public function __construct(array $values = [])
{
$this->values = $values;
}
public function getSetting(string $key, $default = null)
{
return $this->values[$key] ?? $default;
}
}
class UiTest extends TestCase
{
public function testAssetUrlCarriesVersionStamp(): void
{
$url = \Ui::asset('assets/global.css');
$this->assertStringStartsWith('assets/global.css?v=', $url);
$this->assertMatchesRegularExpression('/\?v=\d+$/', $url);
}
public function testAssetUrlRespectsBasePath(): void
{
$this->assertStringStartsWith('../assets/global.css?v=', \Ui::asset('assets/global.css', '../'));
}
public function testBrandingStyleIsEmptyForDefaults(): void
{
$db = new FakeSettings([
'brand_accent' => \Ui::DEFAULT_ACCENT,
'brand_accent_dark' => \Ui::DEFAULT_ACCENT_DARK,
'brand_gradient_from' => \Ui::DEFAULT_GRADIENT_FROM,
'brand_gradient_to' => \Ui::DEFAULT_GRADIENT_TO,
'brand_radius' => (string)\Ui::DEFAULT_RADIUS,
]);
$this->assertSame('', \Ui::brandingStyle($db));
$this->assertSame('', \Ui::brandingStyle(null));
}
public function testBrandingStyleUsesCustomColour(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => '#0F766E']));
$this->assertStringContainsString('--accent:#0f766e', $style);
$this->assertStringContainsString('[data-theme="dark"]', $style);
}
public function testBrandingStyleIgnoresInvalidColour(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => 'rot; background:url(x)']));
$this->assertSame('', $style, 'Ungueltige Farben duerfen keinen Override erzeugen');
}
public function testBrandingRadiusIsClamped(): void
{
$style = \Ui::brandingStyle(new FakeSettings(['brand_radius' => '999']));
$this->assertStringContainsString('--r-lg:28px', $style);
}
public function testMediaUrlKeepsAbsoluteAddresses(): void
{
$this->assertSame('https://cdn.example.com/logo.svg', \Ui::mediaUrl('https://cdn.example.com/logo.svg', '../'));
$this->assertSame('/logo.svg', \Ui::mediaUrl('/logo.svg', '../'));
$this->assertSame('', \Ui::mediaUrl('', '../'));
}
public function testMediaUrlPrefixesUploads(): void
{
$this->assertSame('../uploads/logo.png', \Ui::mediaUrl('uploads/logo.png', '../'));
}
}

72
tests/UploadTest.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use RuntimeException;
require_once __DIR__ . '/../includes/Upload.php';
/**
* Der SVG-Filter ist die sicherheitskritische Stelle beim Bild-Upload:
* hochgeladene Grafiken werden aus der eigenen Domain ausgeliefert, aktive
* Inhalte darin waeren damit gespeichertes XSS.
*/
class UploadTest extends TestCase
{
private function sanitize(string $svg): string
{
$method = new ReflectionMethod(\Upload::class, 'sanitizeSvg');
$method->setAccessible(true);
return $method->invoke(null, $svg);
}
public function testRemovesScriptElement(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect/></svg>');
$this->assertStringNotContainsString('<script', $clean);
$this->assertStringNotContainsString('alert(1)', $clean);
$this->assertStringContainsString('<rect/>', $clean);
}
public function testRemovesEventHandlers(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect onclick=\'steal()\'/></svg>');
$this->assertStringNotContainsString('onload', $clean);
$this->assertStringNotContainsString('onclick', $clean);
}
public function testRemovesJavascriptLinks(): void
{
$clean = $this->sanitize('<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="javascript:alert(1)">x</a></svg>');
$this->assertStringNotContainsString('javascript:', $clean);
}
public function testKeepsHarmlessMarkup(): void
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4" fill="#0f766e"/></svg>';
$this->assertSame($svg, $this->sanitize($svg));
}
public function testRejectsNonSvgContent(): void
{
$this->expectException(RuntimeException::class);
$this->sanitize('GIF89a<html>');
}
public function testIsLocalOnlyAcceptsUploadPaths(): void
{
$this->assertTrue(\Upload::isLocal('uploads/abc.png'));
$this->assertFalse(\Upload::isLocal('https://example.com/logo.png'));
$this->assertFalse(\Upload::isLocal('uploads/../config.php'));
$this->assertFalse(\Upload::isLocal(''));
}
}