Files
huntingclubroster/tests/capacity_service.php

84 lines
10 KiB
PHP

<?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();