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 );
}
2026-10-20 06:45:30 -04:00
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' ], '<b>Jane & club</b>' ), '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 } `" );
}