mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
add password-reset smoke test + AdminAuth DI + OneTimeToken empty-context redeem fix
This commit is contained in:
+33
-9
@@ -19,6 +19,29 @@ class AdminAuth
|
|||||||
private const MAX_ATTEMPTS = 5;
|
private const MAX_ATTEMPTS = 5;
|
||||||
private const COOLDOWN_MINUTES = 15;
|
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.
|
* Start the PHP session with hardened cookie parameters.
|
||||||
* Idempotent — safe to call even if session is already active.
|
* Idempotent — safe to call even if session is already active.
|
||||||
@@ -51,8 +74,7 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-load minimal DB just for this lookup.
|
// Lazy-load minimal DB just for this lookup.
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
$db = self::db();
|
||||||
$db = new Database();
|
|
||||||
$hash = $db->getSetting('admin_password_hash');
|
$hash = $db->getSetting('admin_password_hash');
|
||||||
return $hash !== '' ? $hash : null;
|
return $hash !== '' ? $hash : null;
|
||||||
}
|
}
|
||||||
@@ -150,8 +172,7 @@ class AdminAuth
|
|||||||
*/
|
*/
|
||||||
public static function setPasswordHash(string $newHash): void
|
public static function setPasswordHash(string $newHash): void
|
||||||
{
|
{
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
$db = self::db();
|
||||||
$db = new Database();
|
|
||||||
$db->setSetting('admin_password_hash', $newHash);
|
$db->setSetting('admin_password_hash', $newHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +181,7 @@ class AdminAuth
|
|||||||
*/
|
*/
|
||||||
public static function removePasswordHash(): void
|
public static function removePasswordHash(): void
|
||||||
{
|
{
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
$db = self::db();
|
||||||
$db = new Database();
|
|
||||||
$db->setSetting('admin_password_hash', '');
|
$db->setSetting('admin_password_hash', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +196,7 @@ class AdminAuth
|
|||||||
public static function issuePasswordResetToken(int $ttlSeconds = 1800): string
|
public static function issuePasswordResetToken(int $ttlSeconds = 1800): string
|
||||||
{
|
{
|
||||||
require_once APP_ROOT . '/src/OneTimeToken.php';
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
||||||
$db = new Database();
|
$db = self::db();
|
||||||
$ot = new OneTimeToken($db->getPDO());
|
$ot = new OneTimeToken($db->getPDO());
|
||||||
return $ot->issue('password_reset', $ttlSeconds);
|
return $ot->issue('password_reset', $ttlSeconds);
|
||||||
}
|
}
|
||||||
@@ -192,7 +212,7 @@ class AdminAuth
|
|||||||
public static function requestPasswordReset(): bool
|
public static function requestPasswordReset(): bool
|
||||||
{
|
{
|
||||||
require_once APP_ROOT . '/src/SmtpRelay.php';
|
require_once APP_ROOT . '/src/SmtpRelay.php';
|
||||||
$db = new Database();
|
$db = self::db();
|
||||||
|
|
||||||
$to = SmtpRelay::getNotifyEmail($db);
|
$to = SmtpRelay::getNotifyEmail($db);
|
||||||
if ($to === '') {
|
if ($to === '') {
|
||||||
@@ -240,7 +260,7 @@ HTML;
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/OneTimeToken.php';
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
||||||
$db = new Database();
|
$db = self::db();
|
||||||
$ot = new OneTimeToken($db->getPDO());
|
$ot = new OneTimeToken($db->getPDO());
|
||||||
|
|
||||||
$context = $ot->redeem('password_reset', $token);
|
$context = $ot->redeem('password_reset', $token);
|
||||||
@@ -287,6 +307,10 @@ HTML;
|
|||||||
*/
|
*/
|
||||||
public static function logout(): void
|
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();
|
self::startSession();
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
if (ini_get('session.use_cookies')) {
|
if (ini_get('session.use_cookies')) {
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ class OneTimeToken
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redeem a token, marking it used. Returns the stored context (or null).
|
* Redeem a token, marking it used. Returns the stored context (or an
|
||||||
* Returns null when the token is unknown, expired, or already used.
|
* empty array when none was set). Returns null when the token is unknown,
|
||||||
|
* expired, or already used.
|
||||||
*
|
*
|
||||||
* @return mixed|null
|
* @return mixed|null
|
||||||
*/
|
*/
|
||||||
@@ -75,7 +76,7 @@ class OneTimeToken
|
|||||||
$stmt->execute([(int) $row['id']]);
|
$stmt->execute([(int) $row['id']]);
|
||||||
|
|
||||||
if ($row['context'] === null || $row['context'] === '') {
|
if ($row['context'] === null || $row['context'] === '') {
|
||||||
return null;
|
return []; // consumed successfully, no payload attached
|
||||||
}
|
}
|
||||||
return json_decode($row['context'], true);
|
return json_decode($row['context'], true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,6 +491,11 @@ test-coverage:
|
|||||||
# Generate HTML coverage report in coverage/
|
# Generate HTML coverage report in coverage/
|
||||||
@vendor/bin/phpunit --coverage-html coverage/ tests/phpunit/
|
@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')]
|
[group('test')]
|
||||||
lint-php:
|
lint-php:
|
||||||
# Static analysis (phpstan) + coding standards (php-cs-fixer)
|
# Static analysis (phpstan) + coding standards (php-cs-fixer)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* smoke-test-password-reset.php — end-to-end smoke test for the password-reset
|
||||||
|
* flow, against a throwaway SQLite DB (never touches the live dev/prod DB).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* 1. Issue a reset token via AdminAuth (shared OneTimeToken model).
|
||||||
|
* 2. Invalid / short / one-time redeem semantics.
|
||||||
|
* 3. A successful redeem installs a new bcrypt hash and invalidates the token.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php scripts/smoke-test-password-reset.php
|
||||||
|
*
|
||||||
|
* Exits 0 on success, 1 on any failure. No email is sent (issue only, not
|
||||||
|
* requestPasswordReset()).
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
|
||||||
|
require_once $root . '/app/bootstrap.php';
|
||||||
|
require_once $root . '/app/src/Database.php';
|
||||||
|
require_once $root . '/app/src/AdminAuth.php';
|
||||||
|
require_once $root . '/app/src/OneTimeToken.php';
|
||||||
|
|
||||||
|
$failures = 0;
|
||||||
|
function check(string $label, bool $ok): void
|
||||||
|
{
|
||||||
|
global $failures;
|
||||||
|
echo ($ok ? " ✓ " : " ✗ ") . $label . "\n";
|
||||||
|
if (!$ok) {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build a throwaway DB ─────────────────────────────────────────────────────
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'xamxam-reset-');
|
||||||
|
unlink($tmp);
|
||||||
|
$tmpDb = $tmp . '.db';
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $tmpDb);
|
||||||
|
$pdo->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);
|
||||||
Reference in New Issue
Block a user