E-Mail-Retry, Voucher-Resend, Tageslimit, Docker-Politur

- Mailer::send mit Retry (2 Versuche); SMTP-Test & Templates waren bereits da
- admin/vouchers.php: Code per E-Mail (erneut) versenden (ajax_resend)
- Tageslimit Voucher pro Nicht-Admin-Benutzer (Setting + Durchsetzung in index)
- Docker: HEALTHCHECK (health.php) + curl; GHCR-Publish-Workflow
- Setting user_daily_voucher_limit + enforce in integrations.php
This commit is contained in:
Claude 2026-06-05 20:56:26 +00:00
parent 2e0f36d6c1
commit 997cda01a8
No known key found for this signature in database
14 changed files with 350 additions and 8 deletions

35
tests/ApiKeyTest.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/ApiKey.php';
final class ApiKeyTest extends TestCase
{
public function testGenerateFormat(): void
{
$k = \ApiKey::generate();
$this->assertStringStartsWith('uvt_', $k['plain']);
$this->assertSame(44, strlen($k['plain']));
$this->assertSame(hash('sha256', $k['plain']), $k['hash']);
$this->assertSame(substr($k['plain'], 4, 8), $k['prefix']);
}
public function testScopes(): void
{
$read = ['scope' => 'read'];
$write = ['scope' => 'write'];
$this->assertTrue(\ApiKey::hasScope($read, 'read'));
$this->assertFalse(\ApiKey::hasScope($read, 'write'));
$this->assertTrue(\ApiKey::hasScope($write, 'read'));
$this->assertTrue(\ApiKey::hasScope($write, 'write'));
}
public function testFromRequestBearer(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer uvt_testkey123';
$this->assertSame('uvt_testkey123', \ApiKey::fromRequest());
unset($_SERVER['HTTP_AUTHORIZATION']);
}
}

39
tests/CryptoTest.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
if (!defined('APP_KEY')) {
define('APP_KEY', base64_encode(random_bytes(32)));
}
require_once __DIR__ . '/../includes/Crypto.php';
final class CryptoTest extends TestCase
{
public function testRoundtrip(): void
{
$plain = 'geheim;mit"sonder@zeichen';
$cipher = \Crypto::encrypt($plain);
$this->assertNotSame($plain, $cipher);
$this->assertTrue(\Crypto::isEncrypted($cipher));
$this->assertSame($plain, \Crypto::decrypt($cipher));
}
public function testPlaintextPassthrough(): void
{
// Legacy-/Klartextwerte werden unverändert zurückgegeben.
$this->assertSame('altesKlartextPW', \Crypto::decrypt('altesKlartextPW'));
}
public function testEmptyValues(): void
{
$this->assertSame('', \Crypto::encrypt(''));
$this->assertNull(\Crypto::decrypt(null));
}
public function testGenerateKeyLength(): void
{
$key = \Crypto::generateKey();
$this->assertSame(32, strlen(base64_decode($key)));
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../updater/MigrationRunner.php';
final class MigrationSplitterTest extends TestCase
{
private function split(string $sql): array
{
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
$inst = $rc->newInstanceWithoutConstructor();
$m = $rc->getMethod('splitStatements');
$m->setAccessible(true);
$parts = $m->invoke($inst, $sql);
return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== ''));
}
public function testIgnoresSemicolonsInStringsAndComments(): void
{
$sql = "INSERT INTO t (a) VALUES (\";semi;colon\"); -- comment; not split\n"
. "CREATE TABLE x (id INT); /* block ; comment */ INSERT INTO y VALUES (1);";
$parts = $this->split($sql);
$this->assertCount(3, $parts);
}
public function testSingleStatement(): void
{
$parts = $this->split("ALTER TABLE users ADD COLUMN foo INT");
$this->assertCount(1, $parts);
}
public function testIgnorableErrorDetection(): void
{
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
$inst = $rc->newInstanceWithoutConstructor();
$this->assertTrue($inst->isIgnorableSqlError('Duplicate column name "x"', 'mysql'));
$this->assertTrue($inst->isIgnorableSqlError('Table already exists', 'mysql'));
$this->assertFalse($inst->isIgnorableSqlError('Syntax error near FROM', 'mysql'));
}
}

46
tests/TotpTest.php Normal file
View file

@ -0,0 +1,46 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Totp.php';
final class TotpTest extends TestCase
{
/** RFC 6238 Testvektoren (SHA1, 6 Stellen, Seed "12345678901234567890"). */
public function testRfc6238Vectors(): void
{
$secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; // Base32 des RFC-Seeds
$this->assertSame('287082', \Totp::code($secret, intdiv(59, 30)));
$this->assertSame('081804', \Totp::code($secret, intdiv(1111111109, 30)));
$this->assertSame('005924', \Totp::code($secret, intdiv(1234567890, 30)));
}
public function testVerifyAcceptsCurrentCode(): void
{
$secret = \Totp::generateSecret();
$code = \Totp::code($secret);
$this->assertTrue(\Totp::verify($secret, $code));
}
public function testVerifyRejectsWrongCode(): void
{
$secret = \Totp::generateSecret();
$wrong = \Totp::code($secret) === '000000' ? '111111' : '000000';
$this->assertFalse(\Totp::verify($secret, $wrong));
}
public function testVerifyRejectsMalformed(): void
{
$secret = \Totp::generateSecret();
$this->assertFalse(\Totp::verify($secret, 'abcdef'));
$this->assertFalse(\Totp::verify($secret, '12345'));
}
public function testProvisioningUri(): void
{
$uri = \Totp::provisioningUri('ABC', 'user@example.com', 'My App');
$this->assertStringStartsWith('otpauth://totp/', $uri);
$this->assertStringContainsString('secret=ABC', $uri);
}
}