generated from jric11/baseProject
ChatGPT's update to harden software and run db updates.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
if(PHP_SAPI!=='cli')exit;
|
||||
require __DIR__.'/../classes/Base.php';
|
||||
ob_start();session_start();
|
||||
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable test database required.');
|
||||
$db=(new Base())->connect();$ids=json_decode(getenv('HCR_TENANT_IDS'),true,512,JSON_THROW_ON_ERROR);$checks=0;
|
||||
function capacityCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
|
||||
function capacityDenied(callable $work,int $status,string $message):void{try{$work();}catch(AppException $e){capacityCheck($e->status===$status,$message);return;}throw new RuntimeException($message.' was allowed');}
|
||||
function capacityActor(int $id):void{$_SESSION=[Base::USER_SERIAL=>$id];}
|
||||
$roster=new RosterService();$tickets=new ClubTickets();$resources=new ClubResources();
|
||||
capacityActor(1);
|
||||
$hash=password_hash('Capacity test password!',PASSWORD_DEFAULT);
|
||||
$user=$db->prepare("INSERT INTO users(user_name,user_email,user_first_name,user_last_name,user_password_hash,user_email_verified_at,user_role) VALUES(?,?,?, ?,?,UTC_TIMESTAMP(),'User')");
|
||||
$membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,joined_on) VALUES(?,?,'2026-01-01')");
|
||||
$season=$db->prepare("INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,'2026-01-01','2026-12-31')");
|
||||
$due=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,1)');
|
||||
$payment=$db->prepare("INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,recorded_by) VALUES(?,1,'2026-01-01',?,1)");
|
||||
$adjustment=$db->prepare("INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,recorded_by,request_key) VALUES(?,1,101,'test',1,?)");
|
||||
$invite=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,invited_by,expires_at,sent_at) VALUES(?,?,1,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP())");
|
||||
for($i=1;$i<=76;$i++){
|
||||
$user->execute(['capacity'.$i,'capacity'.$i.'@example.test','Capacity','Member '.sprintf('%03d',$i),$hash]);$uid=(int)$db->lastInsertId();$membership->execute([$ids['clubA'],$uid]);$mid=(int)$db->lastInsertId();
|
||||
$season->execute([$ids['clubA'],'Capacity season '.sprintf('%03d',$i)]);$seasonid=(int)$db->lastInsertId();
|
||||
$due->execute([$ids['clubA'],$mid,$seasonid,100]);$did=(int)$db->lastInsertId();$payment->execute([$did,bin2hex(random_bytes(32))]);$adjustment->execute([$did,bin2hex(random_bytes(32))]);
|
||||
$invite->execute([$ids['clubA'],'pending'.$i.'@example.test']);
|
||||
}
|
||||
$ticket=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$ticket->execute([$ids['clubA'],$ids['membera'],'Long conversation']);$tid=(int)$db->lastInsertId();
|
||||
$message=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');for($i=1;$i<=76;$i++)$message->execute([$tid,$ids['membera'],'Message '.$i]);
|
||||
capacityActor($ids['admina2']);
|
||||
foreach(['members','seasons','dues'] as $kind){
|
||||
$rows=$roster->$kind($ids['clubA'],1);capacityCheck(count($rows)===25,"$kind page is bounded at 25 rows");
|
||||
$last=$roster->$kind($ids['clubA'],9999);capacityCheck(count($last)>0&&count($last)<=25&&BoundedList::$pages[$kind]['page']===BoundedList::$pages[$kind]['pages'],"$kind clamps high page to reachable final records");
|
||||
}
|
||||
$history=(new LedgerService())->history($ids['clubA'],1,1);capacityCheck(count($history['payments'])===25&&count($history['adjustments'])===25,'Payment and correction histories have independent bounded pages');
|
||||
capacityCheck(count((new ClubInvitations())->forClub($ids['clubA'],1))===25,'Administrator invitation list is bounded');
|
||||
capacityCheck(count($tickets->messages($ids['clubA'],$tid,1))===25&&count($tickets->messages($ids['clubA'],$tid,4))===1,'Long conversation exposes all replies across bounded pages');
|
||||
capacityDenied(fn()=>$roster->members($ids['clubB'],1),403,'Paging cannot access another tenant');
|
||||
capacityActor($ids['membera']);capacityCheck(count($roster->members($ids['clubA'],1))===1&&BoundedList::$pages['members']['count']===1,'Member roster rows and total remain personal');
|
||||
capacityCheck(count($roster->dues($ids['clubA'],1))===0&&BoundedList::$pages['dues']['count']===0,'Member dues total excludes other members');
|
||||
capacityActor($ids['adminb']);capacityDenied(fn()=>$tickets->messages($ids['clubA'],$tid,1),403,'Conversation paging cannot expose foreign replies');
|
||||
capacityActor(1);putenv('HCR_EXPORT_MAX_ROWS=30');
|
||||
capacityDenied(fn()=>BoundedList::exportGuard($db,'SELECT COUNT(*) FROM club_memberships WHERE club_serial=?',[$ids['clubA']]),422,'Oversized export is rejected before loading its rows');
|
||||
BoundedList::$exporting=true;capacityDenied(fn()=>$roster->members($ids['clubA']),422,'Export query itself remains bounded if preflight count is bypassed');BoundedList::$exporting=false;
|
||||
$_GET=['Xaction'=>'Roster.workspace','club_serial'=>(string)$ids['clubA'],'dues_page'=>'2'];$roster->members($ids['clubA'],2);$nav=BoundedList::navigation('members');capacityCheck(str_contains($nav,'members_page=3')&&str_contains($nav,'dues_page=2'),'Page navigation preserves independent list context');
|
||||
$_GET['members_page']='-1';capacityDenied(fn()=>BoundedList::page('members'),422,'Invalid list page is rejected');$_GET=[];
|
||||
foreach(['email','ticket','reply','export'] as $resource){
|
||||
putenv('HCR_CLUB_'.strtoupper($resource).'_HOURLY=2');capacityActor($ids['admina']);$resources->consume($ids['clubA'],$resource);capacityActor($ids['admina2']);$resources->consume($ids['clubA'],$resource);
|
||||
capacityDenied(fn()=>$resources->consume($ids['clubA'],$resource),429,"$resource budget is shared across administrators");
|
||||
capacityActor($ids['adminb']);$resources->consume($ids['clubB'],$resource);capacityCheck(true,"$resource budget is independent for another tenant");
|
||||
$stmt=$db->prepare('UPDATE club_resource_usage SET window_started=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 2 HOUR) WHERE club_serial=? AND resource_name=?');$stmt->execute([$ids['clubA'],$resource]);
|
||||
capacityActor($ids['admina2']);$resources->consume($ids['clubA'],$resource);capacityCheck(true,"$resource budget resets after its window");
|
||||
}
|
||||
capacityActor($ids['memberb']);capacityDenied(fn()=>$resources->consume($ids['clubA'],'email'),403,'Unauthorized resource request is denied before budget consumption');
|
||||
putenv('HCR_CLUB_TICKET_HOURLY=invalid');capacityActor($ids['membera']);try{$resources->consume($ids['clubA'],'ticket');throw new LogicException('Invalid limit accepted');}catch(RuntimeException $e){capacityCheck(str_contains($e->getMessage(),'Invalid club'),'Invalid budget configuration fails closed');}
|
||||
putenv('HCR_CLUB_TICKET_HOURLY');putenv('HCR_EXPORT_MAX_ROWS');
|
||||
// Idempotent migration must preserve both factors and counters.
|
||||
$before=$db->query('SELECT * FROM club_resource_usage ORDER BY club_serial,resource_name')->fetchAll();require __DIR__.'/../bin/migrate-security-capacity.php';capacityCheck($before===$db->query('SELECT * FROM club_resource_usage ORDER BY club_serial,resource_name')->fetchAll(),'Security-capacity migration is repeatable without resetting budgets');
|
||||
// Exercise the real factor service, including rollback and password reset.
|
||||
capacityActor(1);$_SESSION['auth_version']=(int)$db->query('SELECT user_auth_version FROM users WHERE user_serial=1')->fetchColumn();
|
||||
$mfa=new MfaService();$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();
|
||||
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher) VALUES(1,?)');$stmt->execute([$mfa->sealSecret($secret,1)]);
|
||||
$code=OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now();
|
||||
capacityCheck($mfa->verify(1,$code,'Admin test password 123!'),'A fresh authenticator code verifies through the maintained library');
|
||||
capacityCheck(!$mfa->verify(1,$code,'Admin test password 123!'),'The same authenticator time step cannot be reused');
|
||||
$recovery=bin2hex(random_bytes(16));$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(1,?)');$stmt->execute([hash('sha256',$recovery)]);
|
||||
$db->exec("CREATE TRIGGER reject_mfa_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
|
||||
try{$mfa->verify(1,$recovery,'Admin test password 123!');throw new LogicException('Audit failure accepted');}catch(PDOException $e){capacityCheck((int)$db->query('SELECT COUNT(*) FROM mfa_recovery WHERE user_serial=1')->fetchColumn()===1,'Failed mandatory audit rolls back recovery-code consumption');}
|
||||
$db->exec('DROP TRIGGER reject_mfa_audit');capacityCheck($mfa->verify(1,$recovery,'Admin test password 123!'),'Recovery code still works after a rolled-back attempt');
|
||||
capacityCheck(!$mfa->verify(1,$recovery,'Admin test password 123!'),'Recovery code is single use after commit');
|
||||
$cipher=$db->query('SELECT secret_cipher FROM user_mfa WHERE user_serial=1')->fetchColumn();
|
||||
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher) VALUES(?,?)');$stmt->execute([$ids['memberb'],$cipher]);
|
||||
capacityActor($ids['memberb']);$stmt=$db->prepare('SELECT user_auth_version FROM users WHERE user_serial=?');$stmt->execute([$ids['memberb']]);$_SESSION['auth_version']=(int)$stmt->fetchColumn();
|
||||
try{$mfa->verify($ids['memberb'],$code,'Tenant test password 123!');throw new LogicException('Copied ciphertext accepted');}catch(RuntimeException $e){capacityCheck(str_contains($e->getMessage(),'unlock'),'Encrypted secret is bound to its owning account');}
|
||||
class CapacityReset extends AuthService {
|
||||
public string $token='';
|
||||
public function connect():PDO {return $GLOBALS['db'];}
|
||||
public function getSettings(){return new SimpleXMLElement('<Settings><ApplicationUrl>https://example.test</ApplicationUrl></Settings>');}
|
||||
protected function deliverResetEmail(array $user,string $url):bool{parse_str(parse_url($url,PHP_URL_QUERY),$query);$this->token=$query['token'];return true;}
|
||||
}
|
||||
$auth=new CapacityReset();$before=$db->query('SELECT * FROM user_mfa WHERE user_serial=1')->fetch();$auth->requestReset('admin@example.test');
|
||||
capacityCheck($auth->resetPassword($auth->token,'Reset preserves MFA 123!','Reset preserves MFA 123!'),'Password reset works for an MFA-enrolled account');
|
||||
capacityCheck($before===$db->query('SELECT * FROM user_mfa WHERE user_serial=1')->fetch(),'Password reset preserves the authenticator and replay counter');
|
||||
$_SESSION['mfa_user']=1;capacityCheck($auth->login('admin@example.test','Reset preserves MFA 123!')&&!$mfa->verified(1),'A password login clears any earlier MFA verification');
|
||||
echo "$checks capacity service checks passed.\n";ob_end_flush();
|
||||
Reference in New Issue
Block a user