Files
huntingclubroster/tests/auth_integration.php
T

136 lines
9.8 KiB
PHP
Raw Normal View History

2026-10-18 19:27:17 -04:00
<?php
/** Destructive only inside a new, randomly named test database. Never reads project DB credentials. */
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
require_once __DIR__ . '/../classes/Base.php';
if (!getenv('HCR_TEST_DSN')) throw new RuntimeException('Set HCR_TEST_DSN to an isolated MySQL/MariaDB server (without dbname).');
$server = new PDO(getenv('HCR_TEST_DSN'), getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$database = 'hcr_auth_test_' . bin2hex(random_bytes(6));
$server->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$server->exec("USE `{$database}`");
$server->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$server->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
session_start();
class TestAuth extends AuthService {
public array $mailbox = [];
public bool $mailWorks = true;
public function __construct(private PDO $db) { parent::__construct(); }
public function connect(): PDO { return $this->db; }
public function getSettings() { return new SimpleXMLElement('<Settings><ApplicationUrl>https://roster.example.test/club</ApplicationUrl></Settings>'); }
protected function deliverResetEmail(array $user, string $url): bool {
$this->mailbox[] = ['email' => $user['user_email'], 'url' => $url];
return $this->mailWorks;
}
}
class CaptureMailer extends Email {
public array $message = [];
public function sendEmail($to = '', $subject = '', $message = '', $attachments = '', $attachment_names = 'unknown') {
$this->message = ['to' => $to, 'subject' => $subject, 'body' => $message];
return true;
}
}
$checks = [];
function check(bool $condition, string $message): void {
global $checks;
if (!$condition) throw new RuntimeException('FAILED: ' . $message);
$checks[] = $message;
}
function rejects(callable $operation, string $message): void {
try { $operation(); } catch (InvalidArgumentException $e) { check(true, $message); return; }
check(false, $message);
}
function tokenFrom(array $email): string {
parse_str(parse_url($email['url'], PHP_URL_QUERY), $query);
return $query['token'];
}
try {
require __DIR__ . '/../tables/users.php';
$server->exec($SQL);
// Exercise the upgrade path from the supplied pre-change schema too.
$server->exec('ALTER TABLE users DROP COLUMN user_auth_version');
foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
if (trim($sql)) $server->exec($sql);
}
require __DIR__.'/../tables/journal.php';$server->exec($SQL);
2026-10-18 19:27:17 -04:00
require __DIR__ . '/../views/view_users.php';
$server->exec($SQL);
$auth = new TestAuth($server);
$auth->initializeSession();
$input = ['user_first_name' => 'Jane', 'user_last_name' => 'Hunter', 'user_email' => 'jane@example.test', 'user_name' => 'jane', 'user_password' => 'A long password 123!', 'user_password_confirm' => 'A long password 123!', 'user_role' => 'Administrator'];
$auth->register($input);
$user = $auth->findUser('jane@example.test');
$id = (int) $user['user_serial'];
check($user['user_role'] === 'User', 'Signup cannot assign administrator privileges');
check(password_verify($input['user_password'], $user['user_password_hash']), 'Signup stores a verifiable password hash');
check($user['user_password_hash'] !== $input['user_password'], 'Signup does not store plaintext passwords');
rejects(fn() => $auth->register($input), 'Duplicate account details are rejected');
rejects(fn() => $auth->register(array_replace($input, ['user_email' => 'invalid'])), 'Invalid email is rejected on the server');
rejects(fn() => $auth->register(array_replace($input, ['user_password_confirm' => 'different'])), 'Mismatched passwords are rejected on the server');
rejects(fn() => $auth->register(array_replace($input, ['user_password' => 'short', 'user_password_confirm' => 'short'])), 'Short passwords are rejected');
rejects(fn() => $auth->register(array_replace($input, ['user_password' => str_repeat('x', 73), 'user_password_confirm' => str_repeat('x', 73)])), 'Passwords above bcrypt byte limit are rejected');
check(!$auth->login("' OR 1=1 -- ", 'wrong'), 'SQL injection input cannot log in');
check(!$auth->login('jane@example.test', 'wrong'), 'Wrong password cannot log in');
$oldSession = session_id();
check($auth->login('jane@example.test', $input['user_password']), 'Email login succeeds');
check(session_id() !== $oldSession, 'Login rotates session ID');
check($auth->currentUser() !== null, 'Authenticated session is valid');
check($server->query('SELECT user_last_login FROM users')->fetchColumn() !== null, 'Login timestamp uses the correct column');
check($auth->login('jane', $input['user_password']), 'Username login remains supported');
$before = $auth->findUser('jane')['user_password_hash'];
$auth->requestReset('jane@example.test');
check(count($auth->mailbox) === 1, 'Reset request generates an email');
check($auth->findUser('jane')['user_password_hash'] === $before, 'Reset request leaves password unchanged');
$token = tokenFrom($auth->mailbox[0]);
check(strlen($token) === 64, 'Reset token contains 256 bits of randomness');
check($server->query('SELECT token_hash FROM password_resets')->fetchColumn() === hash('sha256', $token), 'Database stores only a hash of the reset token');
$auth->requestReset('missing@example.test');
check(count($auth->mailbox) === 1, 'Unknown accounts do not produce reset email');
check(!$auth->resetPassword(str_repeat('0', 64), 'Another long password!', 'Another long password!'), 'Unknown reset token is rejected');
$server->exec('UPDATE password_resets SET expires_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 SECOND)');
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Expired reset token is rejected');
$auth->requestReset('jane@example.test');
$token = tokenFrom($auth->mailbox[1]);
check($auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Valid reset token changes password');
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Reset token cannot be reused');
check($auth->currentUser() === null, 'Password reset revokes an existing session');
check(!$auth->login('jane', $input['user_password']), 'Old password fails after reset');
check($auth->login('jane', 'Another long password!'), 'New password works after reset');
rejects(fn() => $auth->changePassword($id, 'Another long password!', 'New password 123!', 'mismatch'), 'Password change rejects mismatched confirmation');
check(!$auth->changePassword($id, 'wrong', 'New password 123!', 'New password 123!'), 'Password change requires the current password');
check($auth->changePassword($id, 'Another long password!', 'New password 123!', 'New password 123!'), 'Authenticated password change succeeds');
check($auth->currentUser() === null, 'Password change revokes previous sessions');
check($auth->login('jane', 'New password 123!'), 'Changed password works');
$_SESSION['last_activity'] = time() - 1801;
check($auth->currentUser() === null, 'Idle sessions expire on the server');
$server->exec("UPDATE users SET user_status = 'inactive'");
check(!$auth->login('jane', 'New password 123!'), 'Inactive users cannot log in');
$server->exec("UPDATE users SET user_status = 'active'");
$auth->mailWorks = false;
$beforeFailedEmail = $auth->findUser('jane')['user_password_hash'];
try { $auth->requestReset('jane@example.test'); check(false, 'Mail failure must be detected'); }
catch (RuntimeException $e) { check($e->getMessage() === 'Password reset email delivery failed.', 'Email failure is detected'); }
check((int) $server->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() === 0, 'Failed email delivery removes its token');
check($auth->findUser('jane')['user_password_hash'] === $beforeFailedEmail, 'Email failure preserves the existing password');
$mailer = new CaptureMailer();
$mailer->sendPasswordReset(['user_name' => 'jane', 'user_first_name' => '<b>Jane & club</b>', 'user_email' => 'jane@example.test'], 'https://app.myhuntingclubroster.com/main.php?Xaction=resetpassword&token=example');
check($mailer->message['to'] === 'jane@example.test', 'Reset email uses the stored account email');
check(str_contains($mailer->message['body'], 'https://app.myhuntingclubroster.com/main.php'), 'Reset email contains the configured application link');
check(str_contains($mailer->message['body'], '&lt;b&gt;Jane &amp; club&lt;/b&gt;'), 'Reset email escapes account names');
check(!str_contains($mailer->message['body'], 'New password 123!'), 'Reset email never includes an account password');
for ($i = 0; $i < 3; $i++) $allowed = $auth->allowAttempt('test', 'address', 2, 60);
check(!$allowed, 'Rate limits reject excessive attempts');
$server->exec('UPDATE auth_attempts SET window_started = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)');
check($auth->allowAttempt('test', 'address', 2, 60), 'Rate limits recover after their window');
$freshAuth = new TestAuth($server);
for ($i = 0; $i < 11; $i++) $allowed = $freshAuth->login('jane', 'wrong');
check(!$freshAuth->login('jane', 'New password 123!'), 'Login rate limits survive new service instances');
foreach (['login', 'createAccount', 'resetPassword', 'changePassword', 'content'] as $template) {
$html = $auth->applyXSL('<XML/>', $auth->getXSL($template));
check(is_string($html) && strlen($html) > 100, $template . ' stylesheet renders');
}
foreach ($checks as $message) echo "PASS: {$message}\n";
echo count($checks) . " checks passed.\n";
} finally {
$server->exec("DROP DATABASE `{$database}`");
}