generated from jric11/baseProject
Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled
Security and workflow regressions / regression (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
if(PHP_SAPI!=='cli')exit;
|
||||
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('A disposable test database is required.');
|
||||
require __DIR__.'/../classes/Base.php';ob_start();session_start();
|
||||
$db=(new Base())->connect();$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;
|
||||
$checks=0;
|
||||
function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException('FAILED: '.$message);$checks++;echo 'PASS: '.$message."\n";}
|
||||
function rejects(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}throw new RuntimeException('FAILED: '.$message);}
|
||||
class ReviewAuth extends AuthService {
|
||||
public array $mail=[];public $onDelivery=null;public $onFind=null;
|
||||
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
|
||||
public function findUser(string $identifier):?array {$user=parent::findUser($identifier);if($this->onFind){$call=$this->onFind;$this->onFind=null;$call();}return $user;}
|
||||
protected function deliverResetEmail(array $user,string $url):bool {$this->mail[]=$url;if($this->onDelivery){$call=$this->onDelivery;$this->onDelivery=null;$call();}return true;}
|
||||
public function token():string {parse_str(parse_url(end($this->mail),PHP_URL_QUERY),$query);return $query['token'];}
|
||||
}
|
||||
class ReviewVerification extends EmailVerification {
|
||||
public string $url='';public bool $mailWorks=true;
|
||||
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
|
||||
protected function deliver(array $user,string $url):bool {$this->url=$url;return $this->mailWorks;}
|
||||
public function token():string {parse_str(parse_url($this->url,PHP_URL_QUERY),$query);return $query['token'];}
|
||||
}
|
||||
$auth=new ReviewAuth();$service=new MaintenanceService();$password='Production test password!';
|
||||
$input=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_password'=>$password,'user_password_confirm'=>$password];$auth->register($input);
|
||||
$id=(int)$auth->findUser('securitymember')['user_serial'];
|
||||
$edit=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_role'=>'User','user_active'=>'true'];
|
||||
$auth->requestReset('securitymember');$token=$auth->token();$edit['user_email']='changed@example.test';$service->updateUser($id,$edit);
|
||||
verify(!$auth->resetPassword($token,$password,$password),'Old-email reset link is rejected after email change');
|
||||
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$service->setUserActive($id,false);$service->setUserActive($id,true);
|
||||
verify(!$auth->resetPassword($token,$password,$password),'Disable/reactivate does not revive a reset link');
|
||||
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$edit['user_role']='Guest';$service->updateUser($id,$edit);
|
||||
verify(!$auth->resetPassword($token,$password,$password),'Role change invalidates existing reset links');
|
||||
$edit['user_role']='User';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');
|
||||
$auth->onFind=function()use($service,$id,&$edit){$edit['user_email']='raced@example.test';$service->updateUser($id,$edit);};
|
||||
verify(!$auth->requestReset('securitymember'),'Reset issuance rejects a user snapshot superseded by an account edit');
|
||||
verify((int)$db->query('SELECT COUNT(*) FROM password_resets')->fetchColumn()===0,'Stale reset issuance leaves no usable token');
|
||||
$auth->onDelivery=function()use($service,$id,&$edit){$edit['user_email']='later@example.test';$service->updateUser($id,$edit);};$auth->requestReset('securitymember');
|
||||
verify(!$auth->resetPassword($auth->token(),$password,$password),'Email change during delivery invalidates an already-created reset token');
|
||||
$verification=new ReviewVerification();$db->exec('DELETE FROM auth_attempts');
|
||||
verify($verification->request('securitymember'),'Verification link is generated for an unverified account');
|
||||
$token=$verification->token();verify(!str_contains(json_encode($db->query('SELECT * FROM email_verifications')->fetchAll()),$token),'Verification token is stored only as a hash');
|
||||
verify(!$verification->confirm($token,'wrong password'),'Mailbox verification also requires the account password');
|
||||
verify($verification->confirm($token,$password),'Correct verification link and password verify email ownership');
|
||||
verify(!$verification->confirm($token,$password),'Verification links cannot be reused');
|
||||
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Verified ownership is stored');
|
||||
$edit['user_email']='unverified@example.test';$service->updateUser($id,$edit);
|
||||
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing email clears ownership verification');
|
||||
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$db->exec('UPDATE email_verifications SET expires_at=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 SECOND)');
|
||||
verify(!$verification->confirm($token,$password),'Expired email verification link is rejected');
|
||||
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$edit['user_email']='newmail@example.test';$service->updateUser($id,$edit);
|
||||
verify(!$verification->confirm($token,$password),'Email edit invalidates old verification links');
|
||||
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=false;try{$verification->request('securitymember');throw new LogicException('Expected mail failure');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'could not be delivered'),'Verification mail failure is detected');}
|
||||
verify((int)$db->query('SELECT COUNT(*) FROM email_verifications')->fetchColumn()===0,'Undelivered verification token is removed');
|
||||
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');verify($auth->resetPassword($auth->token(),$password,$password),'Password setup/reset can also prove mailbox ownership');
|
||||
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Mailbox ownership is verified after successful password reset');
|
||||
$edit['user_email']='stillunverified@example.test';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');ob_start();$auth->login('securitymember',$password);$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;$_SESSION['auth_version']=(int)$auth->findUser('securitymember')['user_auth_version'];$auth->changePassword($id,$password,$password,$password);ob_end_clean();
|
||||
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing a known password does not falsely verify mailbox ownership');
|
||||
$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;
|
||||
$roster=new RosterService();$club=$roster->saveClub(0,'Security Club');
|
||||
rejects(fn()=>$roster->saveMember($club,0,'securitymember','active','2026-01-01'),'Unverified email cannot receive active club membership');
|
||||
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=true;$verification->request('securitymember');$verification->confirm($verification->token(),$password);
|
||||
$roster->saveMember($club,0,'securitymember','active','2026-01-01');$membership=(int)$db->query('SELECT membership_serial FROM club_memberships')->fetchColumn();$roster->addSeason($club,'Season','2026-01-01','2026-12-31');$season=(int)$db->query('SELECT season_serial FROM club_seasons')->fetchColumn();$roster->charge($club,$membership,$season,'100.00');$due=(int)$db->query('SELECT due_serial FROM club_dues')->fetchColumn();$ledger=new LedgerService();
|
||||
$key=bin2hex(random_bytes(32));$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');
|
||||
verify((int)$db->query('SELECT COUNT(*) FROM club_payments')->fetchColumn()===1,'Payment retries preserve exactly one original payment');
|
||||
$key=bin2hex(random_bytes(32));$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);
|
||||
$row=$roster->dues($club)[0];verify((int)$row['original_amount_cents']===10000&&(int)$row['amount_cents']===8000&&(int)$row['paid_cents']===3000,'Dues corrections preserve original amount and calculate effective balance');
|
||||
verify((int)$db->query('SELECT COUNT(*) FROM club_due_adjustments')->fetchColumn()===1,'Dues correction retries are idempotent');
|
||||
rejects(fn()=>$ledger->adjust($club,$due,'20.00','Too low',bin2hex(random_bytes(32))),'Dues cannot be corrected below received payments');
|
||||
$payment=(int)$db->query('SELECT payment_serial FROM club_payments')->fetchColumn();$key=bin2hex(random_bytes(32));$ledger->reverse($club,$payment,'Incorrect check recorded',$key);$ledger->reverse($club,$payment,'Incorrect check recorded',$key);
|
||||
verify((int)$db->query('SELECT COUNT(*) FROM club_payment_reversals')->fetchColumn()===1,'Payment reversal retries are idempotent');
|
||||
$row=$roster->dues($club)[0];verify((int)$row['paid_cents']===0&&(int)$db->query('SELECT amount_cents FROM club_payments')->fetchColumn()===3000,'Reversal restores balance and preserves the original payment');
|
||||
rejects(fn()=>$ledger->reverse($club,$payment,'Again',bin2hex(random_bytes(32))),'A payment cannot be reversed twice');
|
||||
rejects(fn()=>$ledger->adjust($club,$due,'90.00','',bin2hex(random_bytes(32))),'Financial corrections require a reason');
|
||||
$ledger->payment($club,$due,'80.00','2026-01-02',bin2hex(random_bytes(32)),'transfer','replacement');
|
||||
verify((int)$roster->dues($club)[0]['paid_cents']===8000,'Replacement payment settles corrected dues');
|
||||
rejects(fn()=>$ledger->payment($club,$due,'0.01','2026-01-02',bin2hex(random_bytes(32))),'Corrected paid dues still reject overpayment');
|
||||
$history=$ledger->history($club);verify(count($history['payments'])===2&&count($history['adjustments'])===1,'History includes original payments, reversal details, and correction');
|
||||
verify(str_contains((string)$db->query("SELECT journal_entry FROM journal WHERE journal_entry LIKE '%received payment%' LIMIT 1")->fetchColumn(),'amount_cents'),'Financial audit includes the amount and payment identifier');
|
||||
verify((int)$db->query("SELECT COUNT(*) FROM journal WHERE journal_entry LIKE '%saved membership%' OR journal_entry LIKE '%created season%'")->fetchColumn()===2,'Membership and season writes have audit events');
|
||||
rejects(fn()=>(new Journal())->clearJournal(),'Application journal clearing is prohibited');
|
||||
$db->exec("CREATE TRIGGER reject_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Deliberate audit failure'");
|
||||
try{$ledger->adjust($club,$due,'90.00','Audit should fail',bin2hex(random_bytes(32)));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify((int)$roster->dues($club)[0]['amount_cents']===8000,'Financial write rolls back if its mandatory audit fails');}
|
||||
$before=$auth->findUser('securitymember')['user_first_name'];try{$service->updateUser($id,array_replace($edit,['user_first_name'=>'Should rollback']));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify($auth->findUser('securitymember')['user_first_name']===$before,'Account edit rolls back if its audit fails');}
|
||||
$db->exec('DROP TRIGGER reject_audit');
|
||||
verify(!Transport::https(['REMOTE_ADDR'=>'203.0.113.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Untrusted forwarding headers cannot mark a request secure');
|
||||
verify(Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Explicitly trusted proxy can report HTTPS');
|
||||
verify(!Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https,http'],'127.0.0.1'),'Ambiguous forwarded protocol is rejected');
|
||||
verify(Transport::https(['HTTPS'=>'on']),'Direct HTTPS remains supported');
|
||||
putenv('HCR_ENVIRONMENT=Production');putenv('HCR_DB_HOST=remote.example.test');putenv('HCR_DB_TLS_CA');
|
||||
try{(new Base())->connect();throw new LogicException('Expected transport rejection');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'verified TLS'),'Remote production database connections fail closed without a trusted CA');}
|
||||
echo "$checks production security and ledger checks passed.\n";
|
||||
ob_end_flush();
|
||||
Reference in New Issue
Block a user