Files
huntingclubroster/tests/tenants_service.php

50 lines
8.3 KiB
PHP

<?php
if(PHP_SAPI!=='cli')exit;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
require __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();$ids=json_decode(getenv('HCR_TENANT_IDS'),true,512,JSON_THROW_ON_ERROR);$checks=0;
function checkTenant(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo 'PASS: '.$message."\n";}
function deniedTenant(callable $operation,int $status,string $message):void{try{$operation();}catch(AppException $e){checkTenant($e->status===$status,$message.' (status '.$e->status.')');return;}throw new RuntimeException($message.' was allowed');}
function acting(int $id):void{global $db;$stmt=$db->prepare('SELECT * FROM users WHERE user_serial=?');$stmt->execute([$id]);$u=$stmt->fetch();$_SESSION=[Base::USER_SERIAL=>$id,Base::USER_NAME=>$u['user_name'],Base::USER_ROLE=>$u['user_role'],Base::USER_EMAIL=>$u['user_email']];}
class CapturedClubInvitations extends ClubInvitations {public array $sent=[];public bool $works=true;protected function deliver(string $email,string $club):bool{$this->sent[]=[$email,$club];return $this->works;}}
$access=new TenantAccess();$roster=new RosterService();$invite=new CapturedClubInvitations();$tickets=new ClubTickets();
acting($ids['admina']);
checkTenant(!$access->isSuper()&&$access->canManage($ids['clubA'])&&!$access->canManage($ids['clubB']),'A club administrator has no global or other-club management privilege');
deniedTenant(fn()=>(new MaintenanceService())->updateUser($ids['membera'],[]),403,'Service layer rejects club administrator global account edits');
deniedTenant(fn()=>(new AdminAccounts())->create([]),403,'Service layer rejects club administrator global account creation');
deniedTenant(fn()=>$roster->saveClub(0,'Unauthorized tenant'),403,'Only the Super System Administrator can provision a new tenant');
deniedTenant(fn()=>$roster->saveClub($ids['clubB'],'Renamed other club'),403,'Club administrator cannot rename a club where they are a member');
deniedTenant(fn()=>$roster->saveMember($ids['clubA'],$ids['clubA_membera'],'','active','2026-01-01','administrator'),403,'Club administrator cannot promote a member to administrator');
deniedTenant(fn()=>$roster->saveMember($ids['clubA'],0,'tenantmemberb','active','2026-01-01'),403,'Club administrator cannot look up and attach arbitrary global accounts');
deniedTenant(fn()=>$invite->invite($ids['clubA'],'invitee@example.test','administrator'),403,'Administrator invitations require Super System Administrator authorization');
$result=$invite->invite($ids['clubA'],'invitee@example.test');checkTenant($result['warning']===''&&count($invite->sent)===1,'Club invitation sends to the submitted email without exposing an existing account');
$row=$db->query("SELECT * FROM club_invitations WHERE invite_email='invitee@example.test'")->fetch();$invitation=(int)$row['invitation_serial'];
checkTenant((int)$db->query('SELECT COUNT(*) FROM club_memberships WHERE user_serial='.$ids['invitee'])->fetchColumn()===0,'Invitation grants no membership before authenticated acceptance');
acting($ids['admina2']);checkTenant(count($invite->forClub($ids['clubA']))===1&&$access->managesAny(),'Another administrator of the same club sees shared invitations even with global User role');
acting($ids['adminb']);deniedTenant(fn()=>$invite->forClub($ids['clubA']),403,'Other tenant administrator cannot list invitations');
deniedTenant(fn()=>$invite->revoke($ids['clubB'],$invitation),404,'Changing the club ID cannot revoke another club invitation');
acting($ids['membera']);deniedTenant(fn()=>$invite->accept($invitation),404,'Another account cannot accept an invitation by guessing its ID');
acting($ids['invitee']);checkTenant(count($invite->pending())===1,'Invited email sees its pending invitation');$invite->accept($invitation);
checkTenant(count($roster->clubs())===1&&$roster->clubs()[0]['club_name']==='Tenant Alpha','Accepted invitation grants only its club');
deniedTenant(fn()=>$invite->accept($invitation),404,'Accepted invitation cannot be replayed');
acting($ids['admina']);$invite->invite($ids['clubA'],'unverified@example.test');$unverified=(int)$db->query("SELECT invitation_serial FROM club_invitations WHERE invite_email='unverified@example.test'")->fetchColumn();
acting($ids['unverified']);deniedTenant(fn()=>$invite->accept($unverified),403,'Unverified account cannot accept a club invitation');
$db->exec("UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=".$ids['unverified']);$db->exec('UPDATE club_invitations SET expires_at=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 SECOND) WHERE invitation_serial='.$unverified);
deniedTenant(fn()=>$invite->accept($unverified),404,'Expired invitation cannot be accepted');
acting($ids['admina']);$invite->works=false;$warning=$invite->invite($ids['clubA'],'delivery@example.test');checkTenant($warning['warning']!==''&&$warning['ok'],'Email failure is visible and preserves the invitation for retry');
$revoked=(int)$db->query("SELECT invitation_serial FROM club_invitations WHERE invite_email='delivery@example.test'")->fetchColumn();acting($ids['admina2']);$invite->revoke($ids['clubA'],$revoked);checkTenant($db->query('SELECT invitation_status FROM club_invitations WHERE invitation_serial='.$revoked)->fetchColumn()==='revoked','Shared club administrator can revoke a member invitation');
acting($ids['admina']);$invite->works=true;$invite->invite($ids['clubA'],'shared@example.test');$stale=(int)$db->query("SELECT invitation_serial FROM club_invitations WHERE invite_email='shared@example.test'")->fetchColumn();
$db->exec("UPDATE club_memberships SET membership_role='member' WHERE membership_serial=".$ids['clubA_admina']);acting($ids['shared']);deniedTenant(fn()=>$invite->accept($stale),409,'An invitation cannot grant access after its inviter loses administration');$db->exec("UPDATE club_memberships SET membership_role='administrator' WHERE membership_serial=".$ids['clubA_admina']);
acting(1);$invite->invite($ids['clubB'],'invitee@example.test','administrator');$elevated=(int)$db->query("SELECT invitation_serial FROM club_invitations WHERE club_serial=".$ids['clubB']." AND invite_email='invitee@example.test'")->fetchColumn();acting($ids['invitee']);$invite->accept($elevated);checkTenant($access->canManage($ids['clubB'])&&!$access->canManage($ids['clubA'])&&!$access->isSuper(),'Super-approved club administrator acceptance does not grant global or other-club privileges');
acting($ids['membera']);$ticket=$tickets->createTicket($ids['clubA'],'Membership question','<script>stored as text</script> My dues question');
checkTenant(count($tickets->listing($ids['clubA']))===1,'Member can submit and view a personal club ticket');
deniedTenant(fn()=>$tickets->createTicket($ids['clubB'],'Foreign ticket','Must fail'),403,'Member cannot submit a ticket to another tenant');
acting($ids['shared']);deniedTenant(fn()=>$tickets->ticket($ids['clubA'],$ticket),404,'Another member of the same club cannot read a private ticket');
acting($ids['adminb']);deniedTenant(fn()=>$tickets->reply($ids['clubA'],$ticket,'Foreign reply'),403,'Other club administrator cannot reply to a ticket');
acting($ids['admina2']);$tickets->reply($ids['clubA'],$ticket,'We will review your dues.','closed');checkTenant(count($tickets->messages($ids['clubA'],$ticket))===2,'Assigned club administrator can reply and close a ticket');
acting($ids['membera']);deniedTenant(fn()=>$tickets->reply($ids['clubA'],$ticket,'Reopen please','open'),403,'Member cannot change ticket status');deniedTenant(fn()=>$tickets->reply($ids['clubA'],$ticket,'Another reply'),409,'Closed ticket requires an administrator to reopen it');
acting(1);checkTenant(count($roster->clubs())===2&&count($tickets->listing($ids['clubA']))===1,'Super System Administrator sees all clubs and their tickets');
// Audit failures must roll back tenant data writes.
$db->exec('RENAME TABLE journal TO tenant_hidden_journal');try{$count=(int)$db->query('SELECT COUNT(*) FROM club_tickets')->fetchColumn();try{$tickets->createTicket($ids['clubA'],'Rollback','No audit means no ticket');throw new RuntimeException('Missing audit accepted');}catch(PDOException $e){}checkTenant((int)$db->query('SELECT COUNT(*) FROM club_tickets')->fetchColumn()===$count,'Ticket creation rolls back when its mandatory audit fails');}finally{$db->exec('RENAME TABLE tenant_hidden_journal TO journal');}
echo "$checks tenant service checks passed.\n";