From f31addb6bc94e79747f244d68493f415ea5ad0fd Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 19 Aug 2026 12:33:37 +0200 Subject: [PATCH] add password-reset smoke test + AdminAuth DI + OneTimeToken empty-context redeem fix --- app/src/AdminAuth.php | 42 +++++++++--- app/src/OneTimeToken.php | 7 +- justfile | 5 ++ scripts/smoke-test-password-reset.php | 92 +++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 scripts/smoke-test-password-reset.php diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 398f8fc..131576d 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -19,6 +19,29 @@ class AdminAuth private const MAX_ATTEMPTS = 5; private const COOLDOWN_MINUTES = 15; + /** Test hook: override the Database connection (e.g. temp DB in smoke tests). */ + private static ?Database $dbOverride = null; + + /** + * Return the Database used by AdminAuth. Overridable for tests. + */ + private static function db(): Database + { + if (self::$dbOverride !== null) { + return self::$dbOverride; + } + require_once APP_ROOT . '/src/Database.php'; + return new Database(); + } + + /** + * Inject a Database instance (used by tests / smoke scripts). + */ + public static function setDatabase(Database $db): void + { + self::$dbOverride = $db; + } + /** * Start the PHP session with hardened cookie parameters. * Idempotent — safe to call even if session is already active. @@ -51,8 +74,7 @@ class AdminAuth } // Lazy-load minimal DB just for this lookup. - require_once APP_ROOT . '/src/Database.php'; - $db = new Database(); + $db = self::db(); $hash = $db->getSetting('admin_password_hash'); return $hash !== '' ? $hash : null; } @@ -150,8 +172,7 @@ class AdminAuth */ public static function setPasswordHash(string $newHash): void { - require_once APP_ROOT . '/src/Database.php'; - $db = new Database(); + $db = self::db(); $db->setSetting('admin_password_hash', $newHash); } @@ -160,8 +181,7 @@ class AdminAuth */ public static function removePasswordHash(): void { - require_once APP_ROOT . '/src/Database.php'; - $db = new Database(); + $db = self::db(); $db->setSetting('admin_password_hash', ''); } @@ -176,7 +196,7 @@ class AdminAuth public static function issuePasswordResetToken(int $ttlSeconds = 1800): string { require_once APP_ROOT . '/src/OneTimeToken.php'; - $db = new Database(); + $db = self::db(); $ot = new OneTimeToken($db->getPDO()); return $ot->issue('password_reset', $ttlSeconds); } @@ -192,7 +212,7 @@ class AdminAuth public static function requestPasswordReset(): bool { require_once APP_ROOT . '/src/SmtpRelay.php'; - $db = new Database(); + $db = self::db(); $to = SmtpRelay::getNotifyEmail($db); if ($to === '') { @@ -240,7 +260,7 @@ HTML; } require_once APP_ROOT . '/src/OneTimeToken.php'; - $db = new Database(); + $db = self::db(); $ot = new OneTimeToken($db->getPDO()); $context = $ot->redeem('password_reset', $token); @@ -287,6 +307,10 @@ HTML; */ public static function logout(): void { + // No session in this request (e.g. CLI) — nothing to destroy. + if (session_status() === PHP_SESSION_NONE) { + return; + } self::startSession(); $_SESSION = []; if (ini_get('session.use_cookies')) { diff --git a/app/src/OneTimeToken.php b/app/src/OneTimeToken.php index 01c2faf..f3c50d7 100644 --- a/app/src/OneTimeToken.php +++ b/app/src/OneTimeToken.php @@ -57,8 +57,9 @@ class OneTimeToken } /** - * Redeem a token, marking it used. Returns the stored context (or null). - * Returns null when the token is unknown, expired, or already used. + * Redeem a token, marking it used. Returns the stored context (or an + * empty array when none was set). Returns null when the token is unknown, + * expired, or already used. * * @return mixed|null */ @@ -75,7 +76,7 @@ class OneTimeToken $stmt->execute([(int) $row['id']]); if ($row['context'] === null || $row['context'] === '') { - return null; + return []; // consumed successfully, no payload attached } return json_decode($row['context'], true); } diff --git a/justfile b/justfile index 5313a90..e76f2a5 100644 --- a/justfile +++ b/justfile @@ -491,6 +491,11 @@ test-coverage: # Generate HTML coverage report in coverage/ @vendor/bin/phpunit --coverage-html coverage/ tests/phpunit/ +[group('test')] +smoke-password-reset: + # End-to-end smoke test for the password-reset flow (throwaway DB, no email). + @php scripts/smoke-test-password-reset.php + [group('test')] lint-php: # Static analysis (phpstan) + coding standards (php-cs-fixer) diff --git a/scripts/smoke-test-password-reset.php b/scripts/smoke-test-password-reset.php new file mode 100644 index 0000000..9fb6c0e --- /dev/null +++ b/scripts/smoke-test-password-reset.php @@ -0,0 +1,92 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$pdo->exec('PRAGMA foreign_keys = ON'); + +$schema = APP_ROOT . '/storage/schema.sql'; +$pdo->exec(file_get_contents($schema)); + +$db = new Database($tmpDb); // real connection, isolated file +AdminAuth::setDatabase($db); + +// ── Seed an existing admin password ───────────────────────────────────────── +$db->setSetting('admin_password_hash', password_hash('initial-password-123', PASSWORD_BCRYPT)); +check('admin password hash configured', AdminAuth::hasPassword()); + +// ── 1. Issue a reset token ─────────────────────────────────────────────────── +$token = AdminAuth::issuePasswordResetToken(1800); +check('issued 256-bit hex token', (bool) preg_match('/^[0-9a-f]{64}$/', $token)); + +$ot = new OneTimeToken($db->getPDO()); +check('token is valid (shared model)', $ot->isValid('password_reset', $token)); + +// ── 2. Reject short password ───────────────────────────────────────────────── +check('rejects <12 char password', AdminAuth::redeemPasswordResetToken($token, 'short') === false); + +// ── 3. Successful redeem installs a new hash ──────────────────────────────── +$before = $db->getSetting('admin_password_hash'); +check('redeems valid token + 12-char password', AdminAuth::redeemPasswordResetToken($token, 'new-secure-password-999')); +$after = $db->getSetting('admin_password_hash'); +check('hash changed', $after !== '' && $after !== $before); +check('new password verifies', password_verify('new-secure-password-999', $after)); +check('old password no longer verifies', !password_verify('initial-password-123', $after)); + +// ── 4. One-time use ───────────────────────────────────────────────────────── +check('token now invalid (consumed)', $ot->isValid('password_reset', $token) === false); +check('second redeem fails', AdminAuth::redeemPasswordResetToken($token, 'another-secure-password-000') === false); + +// ── 5. Unknown token fails ────────────────────────────────────────────────── +check('unknown token fails', AdminAuth::redeemPasswordResetToken(str_repeat('ab', 32), 'another-secure-password-000') === false); + +// ── Cleanup ───────────────────────────────────────────────────────────────── +$db->setSetting('admin_password_hash', ''); +unlink($tmpDb); + +echo "\n"; +if ($failures === 0) { + echo "✅ Password-reset smoke test passed.\n"; + exit(0); +} +echo "❌ {$failures} check(s) failed.\n"; +exit(1);