ChatGPT's update to harden software and run db updates.

This commit is contained in:
2026-10-21 06:53:45 -04:00
parent 1e63076123
commit acc8cb70f5
171 changed files with 9325 additions and 404 deletions
+22 -1
View File
@@ -20,7 +20,8 @@ class Application:
if not os.environ.get('HCR_TEST_DSN'):
raise RuntimeError('An isolated HCR_TEST_DSN is required.')
self.env=os.environ.copy()
self.env.update(HCR_APPLICATION_URL='',HCR_ENVIRONMENT='Development')
self.env['HCR_MFA_KEY']='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
self.env.update(HCR_SUPER_ADMIN_EMAIL='',HCR_APPLICATION_URL='',HCR_ENVIRONMENT='Development',HCR_SMTP_HOST='127.0.0.1',HCR_SMTP_PORT='1',HCR_SMTP_USER='test@example.test',HCR_SMTP_PASSWORD='test-only',HCR_SMTP_FROM='test@example.test')
self.database=self.fixture('create')['database']
self.env['HCR_DB_NAME']=self.database
with socket.socket() as available:
@@ -52,7 +53,27 @@ class Application:
_,body,_=self.request()
status,body,_=self.request(data={'action':'SignIn.login','csrf_token':self.csrf(body),'user_email':email,'user_password':password})
if status!=200 or b'name="csrf-token"' not in body:raise AssertionError('Login failed: '+body[:500].decode(errors='replace'))
if b'<h1>Account security</h1>' in body:
if b'setup key' in body:
secret=re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
code=self.totp(secret)
status,body,_=self.request(data={'action':'security','csrf_token':self.csrf(body),'current_password':password,'mfa_code':code})
if b'Save your recovery codes' not in body:raise AssertionError(body[:1000])
recovery=re.findall(rb'\b[a-f0-9]{32}\b',body)
self.recovery=[c.decode() for c in recovery]
status,body,_=self.request()
else:
code=self.fixture('mfaCode',email)['code']
status,body,_=self.request(data={'action':'security','csrf_token':self.csrf(body),'current_password':password,'mfa_code':code})
if status!=200:raise AssertionError('MFA failed: '+str(status))
self.token=self.csrf(body)
@staticmethod
def totp(secret, at=None):
import base64,hashlib,hmac,struct,time
secret=base64.b32decode(secret+'='*((8-len(secret)%8)%8))
digest=hmac.new(secret,struct.pack('>Q',int((time.time() if at is None else at)//30)),hashlib.sha1).digest()
offset=digest[-1]&15
return str((int.from_bytes(digest[offset:offset+4],'big')&0x7fffffff)%1000000).zfill(6)
def post(self,action,**values):
return self.request(data={'action':action,'csrf_token':self.token,**values},ajax=True)
def logout(self):self.post('signout')
+2 -2
View File
@@ -80,7 +80,7 @@ try:
check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected')
status, html, _ = request('/?Xaction=signup')
check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login')
fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator'}
fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator','club_name':'HTTP signup club'}
status, html, _ = request(data=fields)
check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects')
status, html, _ = request()
@@ -89,7 +89,7 @@ try:
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'})
check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
token = csrf(html)
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges')
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator','club_name':'HTTP signup club'})[0] == 403, 'Regular users cannot edit account privileges')
check(request(data={'action': 'RosterReports.csv', 'club_serial': '1', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
+5
View File
@@ -0,0 +1,5 @@
import json,os,subprocess
from pathlib import Path
root=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
subprocess.run(php+['-d','memory_limit=16M','tests/backup_validation.php'],cwd=root,check=True)
+19
View File
@@ -0,0 +1,19 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$checks=0;function checkBackup(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo 'PASS: '.$message."\n";}
$path=tempnam(sys_get_temp_dir(),'hcr-backup-test-');
try{
$padding=str_repeat('-',150)."\n";
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\n");checkBackup(BackupValidation::complete($path),'Complete SQL footer is recognized');
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\nINSERT INTO unfinished");checkBackup(!BackupValidation::complete($path),'A completion comment before a truncated ending is rejected');
file_put_contents($path,$padding.'Incomplete SQL');checkBackup(!BackupValidation::complete($path),'Missing completion footer is rejected');
file_put_contents($path,"-- Dump completed on fake\n");checkBackup(!BackupValidation::complete($path),'Suspiciously short dump is rejected');
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\r\n");checkBackup(BackupValidation::complete($path),'Windows line endings are supported');
$handle=fopen($path,'w+b');fwrite($handle,"-- Large backup validation fixture\n");ftruncate($handle,64*1024*1024);fseek($handle,0,SEEK_END);fwrite($handle,"\n-- Dump completed on 2026-10-20 12:00:00\n");fclose($handle);
checkBackup(BackupValidation::complete($path),'A 64 MiB dump validates under a 16 MiB PHP memory limit');
checkBackup(strlen(hash_file('sha256',$path))===64,'Checksum generation also works with bounded memory');
checkBackup(memory_get_peak_usage(true)<16*1024*1024,'Peak PHP memory stays below the configured limit');
}finally{unlink($path);}
checkBackup(!BackupValidation::complete($path),'Missing backup file is rejected');
echo "$checks backup validation checks passed.\n";
+52 -10
View File
@@ -8,10 +8,11 @@ const fs=require('node:fs/promises');
const errors=[],checks=[];page.on('pageerror',error=>errors.push(error.message));
await page.exposeFunction('recordCspViolation',message=>errors.push(message));await page.addInitScript(()=>document.addEventListener('securitypolicyviolation',event=>window.recordCspViolation('CSP: '+event.violatedDirective+' '+event.blockedURI)));
const pass=message=>{checks.push(message);console.log('PASS: '+message);};
const openClubModal=async step=>{await page.locator('[data-club-step="'+step+'"]').first().click();await page.locator('.club-action-modal.show').waitFor();};
const submit=async data=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(data=>formSubmit(data),data)]);await page.waitForFunction(()=>typeof formSubmit==='function');await page.waitForLoadState('networkidle');};
try{
await page.goto(process.env.HCR_TEST_URL+'/main.php');
await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await page.waitForFunction(()=>typeof formSubmit==='function');
await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await require('./browser_mfa')(page,'Admin test password 123!');await page.waitForFunction(()=>typeof formSubmit==='function');
pass('Browser login loads the application shell without script errors');
const safe=await page.evaluate(()=>{
const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){};
@@ -32,18 +33,59 @@ const fs=require('node:fs/promises');
await page.route('**/*',failUsage);await page.locator('.btnDelete').click();await page.getByText('Deliberate usage-check failure',{exact:true}).waitFor();assert(!await page.locator('#confirm-dialog').isVisible());pass('A failed deletion check shows an error and does not open confirmation');await page.unroute('**/*',failUsage);
await page.locator('.btnDelete').click();await page.locator('#confirm-dialog').waitFor({state:'visible'});await page.locator('#confirm-dialog .btnConfirmDialogConfirm').click();await page.locator('.setupUsers').waitFor();assert(await page.locator('tr.user-row').filter({hasText:'browser_tester'}).count()===0);pass('Deleting an unused account succeeds through the browser');
await submit({action:'Dropdowns.setupDropdowntypes'});await page.locator('.setupDropdowntypes').waitFor();await page.locator('.btnAdd').click();await page.locator('#addDropdowntype').waitFor();await page.locator('#dropdowntype_name').fill('browser_type');await page.locator('#dropdowntype_title').fill('Browser type');await page.locator('.btnSave').click();await page.locator('.setupDropdowntypes').waitFor();pass('Dropdown type form works with restricted usage metadata');
await submit({action:'Alerts.addAlert'});await page.locator('.ck-editor').waitFor();pass('Alert editor initializes with updated jQuery and the security policy');
await submit({action:'Popovers.addPopover'});await page.locator('.ck-editor').waitFor();pass('Popover editor initializes with updated jQuery and the security policy');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.workspace');
const richHtml='<p><strong>Legacy bold</strong> <em>italic</em> <u>underline</u> <a href="https://example.test/">link</a> <span style="color: rgb(255, 0, 0); font-family: Arial; font-size: 18px;">font</span></p><ul><li>Bullet</li></ul><figure class="table"><table><tbody><tr><td>Cell</td></tr></tbody></table></figure>';
for (const kind of ['Alert','Popover']) {
const isAlert=kind==='Alert',field=isAlert?'alert_message':'popover_text',prefix=isAlert?'alert':'popover',controller=kind+'s';
await page.evaluate(({kind,controller})=>{const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=state['edit'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));},{kind,controller});
await submit({action:controller+'.add'+kind});
await page.waitForFunction(field=>!!document.getElementById(field).hcrEditor,field);
assert(await page.locator('.btnSave').isEnabled());pass(kind+' editor initializes locally under the security policy');
await page.evaluate(({field,richHtml,kind,controller})=>{
document.getElementById(field).hcrEditor.$.html.set(richHtml);
const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));
},{field,richHtml,kind,controller});
await page.locator('#'+prefix+'_title').fill('Browser rich '+kind);
if(isAlert){
await page.evaluate(()=>{$('#alert_starts_date').datepicker('update','2026-01-01');$('#alert_expires_date').datepicker('update','2027-12-31');$('#alert_starts_time').inputmask('setvalue','00:00');$('#alert_expires_time').inputmask('setvalue','23:59');});
}else await page.locator('#popover_name').fill('browser_rich_popover');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
const richRow=page.locator('tr.'+prefix+'-row').filter({hasText:isAlert?'Browser rich Alert':'browser_rich_popover'});
const serial=await richRow.getAttribute('data-'+prefix+'-serial');await richRow.click();
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
const content=await page.evaluate(field=>document.getElementById(field).hcrEditor.$.html.get(),field);
assert.match(content,/<strong>Legacy bold<\/strong>/);assert.match(content,/<em>italic<\/em>/);assert.match(content,/<u>underline<\/u>/);assert.match(content,/<table/);assert.match(content,/>Cell</);assert.match(content,/<ul/);assert.match(content,/https:\/\/example.test\//);assert.match(content,/font-family/);assert.match(content,/font-size/);assert.match(content,/color:/);
pass(kind+' add/save/reopen preserves legacy formatting, lists, links, fonts and tables');
await page.locator('.sun-editor [contenteditable=true]').first().press('Control+End');
await page.locator('.sun-editor [contenteditable=true]').first().press('Enter');
await page.locator('.sun-editor [contenteditable=true]').first().pressSequentially('Browser edit');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
await submit({action:controller+'.edit'+kind,[prefix+'_serial']:serial});
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
assert((await page.locator('.sun-editor [contenteditable=true]').first().innerText()).includes('Browser edit'));
pass(kind+' edits made in the visible editor are persisted');
}
await submit({action:'Popovers.addPopover'});await page.waitForFunction(()=>!!document.querySelector('#popover_text').hcrEditor);
const cleaned=await page.evaluate(()=>{const editor=document.querySelector('#popover_text').hcrEditor;editor.$.html.set('<p onclick="window.bad=1">Safe<script>window.bad=1</script><a href="javascript:alert(1)">link</a></p>');return editor.$.html.get();});
assert(!/<script|onclick=|javascript:/i.test(cleaned));pass('Editor content filtering removes scripts, event attributes and unsafe links');
await page.locator('.sun-editor [contenteditable=true]').first().focus();
await page.evaluate(()=>{const transfer=new DataTransfer();transfer.setData('text/html','<p><strong>Pasted formatting</strong></p>');transfer.setData('text/plain','Pasted formatting');document.querySelector('.sun-editor [contenteditable=true]').dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));});
await page.waitForFunction(()=>document.querySelector('#popover_text').hcrEditor.$.html.get().includes('<strong>Pasted formatting</strong>'));pass('Pasting formatted HTML retains bold text');
await page.route('**/js/suneditor-3.3.3.min.js',route=>route.abort());await submit({action:'Alerts.addAlert'});
await page.getByText('The text editor could not load. Reload this page before saving.',{exact:true}).waitFor();assert(await page.locator('.btnSave').isDisabled());pass('Editor load failure is visible and saving stays disabled');await page.unroute('**/js/suneditor-3.3.3.min.js');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.createClub');
let form=page.locator('form').filter({has:page.getByRole('button',{name:'Create club',exact:true})});await form.getByLabel('Club name',{exact:true}).fill('Browser Club');await form.getByRole('button',{name:'Create club',exact:true}).click();await page.getByRole('heading',{name:'Browser Club',exact:true}).waitFor();pass('Club creation works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance');
await page.getByText('Correct dues',{exact:true}).click();
assert.equal(await page.locator('.clubSummary input:visible,.clubSummary select:visible').count(),0);pass('Club summary hides all editing fields until an action is opened');
await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Unsaved name');await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Canceling a club edit leaves the club unchanged');
await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Browser Club');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Save club',exact:true}).click()]);assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Club edits save through their modal and return to the summary');
await openClubModal('member');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser');
await openClubModal('season');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2025-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.locator('.club-action-modal.show .club-modal-error:not([hidden])').waitFor();assert.equal(await form.getByLabel('Season name').inputValue(),'Browser Season');pass('Server validation stays inside the modal and preserves entered values');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser');
await openClubModal('charge');form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser');
await openClubModal('payment');form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance');
await openClubModal('adjust');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save correction',exact:true})});await form.getByLabel('Corrected total (USD)').fill('8.00');await form.getByLabel('Reason',{exact:true}).fill('Correct season rate');await form.getByRole('button',{name:'Save correction',exact:true}).click();await page.getByRole('cell',{name:'$8.00',exact:true}).waitFor();pass('Dues correction updates the balance and preserves history in the browser');
await page.getByText('Reverse incorrect payment',{exact:true}).click();
await openClubModal('reverse');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Confirm reversal',exact:true})});await form.getByLabel('Reversal reason').fill('Wrong receipt');await form.getByRole('button',{name:'Confirm reversal',exact:true}).click();await page.getByText('Reversed: Wrong receipt',{exact:false}).waitFor();pass('Payment reversal is recorded visibly without deleting the original payment');
await openClubModal('invite');await page.locator('.club-action-modal.show').getByLabel('Invitation email',{exact:true}).fill('revocation@example.test');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Send club invitation',exact:true}).click()]);const invitation=page.locator('li').filter({hasText:'revocation@example.test'});await invitation.locator('[data-club-step=revokeInvitation]').click();await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await invitation.textContent()).includes('pending'));await invitation.locator('[data-club-step=revokeInvitation]').click();await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Revoke invitation',exact:true}).click()]);assert((await page.locator('li').filter({hasText:'revocation@example.test'}).textContent()).includes('revoked'));pass('Invitation revocation requires modal confirmation and Cancel preserves the invitation');
const ledgerDownload=page.waitForEvent('download');await page.getByRole('button',{name:'Export financial history (CSV)',exact:true}).click();assert.equal((await ledgerDownload).suggestedFilename(),'club-financial-history.csv');pass('Financial history can be downloaded from the browser');
await page.screenshot({path:path.join(__dirname,'.local','roster.png'),fullPage:true});
await submit({action:'Reports.selectReport'});await page.getByRole('heading',{name:'Roster reports'}).waitFor();const downloadEvent=page.waitForEvent('download');await page.getByRole('button',{name:'Download CSV'}).click();const download=await downloadEvent;assert.equal(download.suggestedFilename(),'club-roster.csv');pass('Report catalog downloads the roster CSV in the browser');
+17
View File
@@ -0,0 +1,17 @@
const crypto=require('node:crypto');
function totp(secret){
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits='';for(const c of secret)bits+=alphabet.indexOf(c).toString(2).padStart(5,'0');
const bytes=[];for(let i=0;i+8<=bits.length;i+=8)bytes.push(parseInt(bits.slice(i,i+8),2));
const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)));
const digest=crypto.createHmac('sha1',Buffer.from(bytes)).update(counter).digest(),offset=digest[19]&15;
return String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');
}
module.exports=async function completeMfa(page,password){
if(!await page.getByRole('heading',{name:'Account security',exact:true}).count())return;
const secret=(await page.locator('pre').textContent()).trim();
await page.locator('[name=current_password]').fill(password);await page.locator('[name=mfa_code]').fill(totp(secret));
await Promise.all([page.waitForNavigation(),page.getByRole('button',{name:'Verify',exact:true}).click()]);
await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();
await Promise.all([page.waitForNavigation(),page.getByRole('link',{name:'I have saved my recovery codes'}).click()]);
};
module.exports.totp=totp;
+1 -1
View File
@@ -4,7 +4,7 @@ from application_harness import Application,ROOT
app=Application()
try:
env=os.environ.copy();env['HCR_TEST_URL']=app.base
env=app.env.copy();env['HCR_TEST_URL']=app.base
node=env.get('NODE_TEST_COMMAND','node')
result=subprocess.run([node,'tests/browser.js'],cwd=ROOT,env=env)
if result.returncode:raise SystemExit(result.returncode)
+83
View File
@@ -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();
+55
View File
@@ -0,0 +1,55 @@
"""New-club signup must never grant administration of an existing tenant."""
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def signup(name='newowner',email='newowner@example.test',club='New owner club',kind='new_club',**extra):
app.fixture('clearTestAttempts')
_,body,_=app.request('/main.php?Xaction=signup')
return app.request(data={'action':'SignUp.createAccount','csrf_token':app.csrf(body),'user_first_name':'New','user_last_name':'Owner','user_email':email,'user_name':name,'user_password':'New owner password 123!','user_password_confirm':'New owner password 123!','registration_kind':kind,'club_name':club,**extra})
try:
ids=app.fixture('tenantSeed');before=app.fixture('inspect')
status,body,_=app.request('/main.php?Xaction=signup')
check(status==200 and b'name="club_name"' in body and b'value="new_club"' in body,'Default signup asks for a new club')
status,body,_=signup(club='')
check(b'Enter your new club name' in body and app.fixture('inspect')==before,'Missing club name creates no account or club')
status,body,_=signup(club='X'*129)
check(b'Enter your new club name' in body and app.fixture('inspect')==before,'Server rejects overlong club names without partial records')
status,body,_=signup(kind='account')
check(b'Choose a valid signup option' in body and app.fixture('inspect')==before,'Public signup cannot use the internal account-only mode')
app.fixture('mfaTestAuditFailure','on')
status,body,_=signup()
app.fixture('mfaTestAuditFailure','off')
check(status==500 and app.fixture('inspect')==before,'Audit failure rolls back account, club and administrator membership')
status,body,_=signup(club='Tenant Alpha',user_role='Super System Administrator',club_serial=ids['clubA'])
state=app.fixture('inspect');owner=next(u for u in state['users'] if u['user_name']=='newowner');memberships=[m for m in state['club_memberships'] if m['user_serial']==owner['user_serial']];club=memberships[0]['club_serial']
check(status==200 and b'Your account has been created' in body,'New-club signup succeeds')
check(owner['user_role']=='User' and owner['user_email_verified_at'] is None,'Signup keeps the global role unprivileged and email unverified')
check(len(memberships)==1 and memberships[0]['membership_role']=='administrator' and club!=ids['clubA'],'Matching an existing club name creates an independent tenant with its own administrator')
before=state;signup()
check(app.fixture('inspect')==before,'Duplicate account submission creates no orphan club')
app.login('newowner@example.test','New owner password 123!')
check(app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(club))[0]==403,'Unverified club owner cannot open the club')
app.fixture('verifyEmail','newowner');app.logout()
# Real login helper enrolls MFA only when the application requires it.
app.login('newowner@example.test','New owner password 123!')
check(bool(app.fixture('mfaState','newowner@example.test')['factor']),'Verified new administrator must enroll an authenticator')
status,body,_=app.request()
check(status==200 and b'class="container-fluid py-3 clubSummary"' in body and b'<h1>Tenant Alpha</h1>' in body,'Verified owner lands directly in the summary-style club page')
check(b'>Create club</button>' not in body and b'Xaction=Roster.createClub' not in body,'Club administrator sees no global club-creation controls')
check(app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(ids['clubA']))[0]==403,'New administrator cannot open the pre-existing club with the same name')
check(app.request('/main.php?Xaction=Roster.createClub')[0]==403,'Dedicated creation page rejects club administrators')
app.logout();before=app.fixture('inspect');status,body,_=signup('notinvited','notinvited@example.test',kind='invited')
check(b'Ask your club administrator' in body and app.fixture('inspect')==before,'Member signup requires a current invitation to the submitted email')
app.fixture('seedTenantInvitation',str(ids['clubA']),'1','newmember@example.test');before=app.fixture('inspect')
status,body,_=signup('newmember','newmember@example.test',kind='invited')
after=app.fixture('inspect');member=next(u for u in after['users'] if u['user_name']=='newmember')
check(status==200 and b'Your account has been created' in body and len(after['clubs'])==len(before['clubs']),'Invited signup creates an account without creating another club')
check(not any(m['user_serial']==member['user_serial'] for m in after['club_memberships']),'Invitation signup does not skip email verification or invitation acceptance')
app.login();status,body,_=app.request('/main.php?Xaction=Roster.createClub')
check(status==200 and b'>Create club</button>' in body,'Super Administrator retains a separate Create club page')
print(checks,'club onboarding HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+17 -5
View File
@@ -50,15 +50,27 @@ try:
assert all(row['ok'] for row in result),result
assert len(app.fixture('inspect')['club_due_adjustments'])==1
print('PASS: Concurrent dues correction retries create only one adjustment.')
response=app.post('Users.addUser',step='add',user_name='second_admin',user_first_name='Second',user_last_name='Administrator',user_email='second@example.test',user_role='Administrator')
response=app.post('Users.addUser',step='add',user_name='second_admin',user_first_name='Second',user_last_name='Administrator',user_email='second@example.test',user_role='Super System Administrator')
assert response[0]==200,response[1]
ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='Administrator']
ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='Super System Administrator']
result=race('disable',ids)
assert sorted(item['ok'] for item in result)==[False,True],result
assert next(item for item in result if not item['ok'])['status']==409,result
assert sum(row['user_role']=='Administrator' and row['user_status']=='active' for row in app.fixture('inspect')['users'])==1
assert next(item for item in result if not item['ok'])['status'] in [403,409],result
assert sum(row['user_role']=='Super System Administrator' and row['user_status']=='active' for row in app.fixture('inspect')['users'])==1
print('PASS: Concurrent administrator changes cannot disable the final administrator.')
print('5 concurrency scenarios passed.')
tenant=app.fixture('tenantSeed')
invitation=app.fixture('seedTenantInvitation',str(tenant['clubA']),str(tenant['admina']),'invitee@example.test')['invitation']
result=race('accept',[invitation,invitation],[tenant['invitee']])
assert sorted(item['ok'] for item in result)==[False,True],result
assert sum(row['user_serial']==tenant['invitee'] and row['club_serial']==tenant['clubA'] for row in app.fixture('inspect')['club_memberships'])==1
print('PASS: Concurrent invitation acceptance grants exactly one membership.')
# Restore the worker actor after the previous last-super race, for this isolated fixture only.
app.fixture('restoreTestSuper')
result=race('membership',[tenant['clubA_admina'],tenant['clubA_admina2']],[tenant['clubA']])
assert sorted(item['ok'] for item in result)==[False,True],result
assert sum(row['club_serial']==tenant['clubA'] and row['membership_role']=='administrator' and row['membership_status']=='active' for row in app.fixture('inspect')['club_memberships'])==1
print('PASS: Concurrent membership changes preserve the last club administrator.')
print('7 concurrency scenarios passed.')
finally:
for process in workers:
if process.poll() is None:process.terminate();process.wait(timeout=10)
+6
View File
@@ -19,6 +19,7 @@ try:
names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json'))
check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests')
check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery')
check(all(name in names for name in ['classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators')
check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file')
binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump')
if not binary:
@@ -33,6 +34,11 @@ try:
check(not list(Path(temporary).glob('hcr-connection-*')),'Backup credentials file is removed after completion')
result=subprocess.run(PHP+['bin/backup-database.php',str(ROOT/'.tools')],cwd=ROOT,env=env,capture_output=True)
check(result.returncode!=0,'Backup tool rejects storage within the application tree')
failed_directory=Path(temporary)/'failed-job';failed_directory.mkdir()
env['HCR_DUMP_BINARY']=PHP[0] # A real executable that rejects dump arguments.
result=subprocess.run(PHP+['bin/backup-database.php',str(failed_directory)],cwd=ROOT,env=env,capture_output=True)
check(result.returncode!=0,'Dump process failure produces a failing job exit status')
check(not list(failed_directory.iterdir()),'Failed backup leaves no credentials, partial dump, or success metadata')
for path in ['/.tools/backups/probe.sql','/migrations/003_production.sql','/bin/check-health.php','/xml/settings.xml','/release-manifest.json']:
check(app.request(path)[0] in [403,404],'Development router denies '+path)
finally:app.close()
+86 -1
View File
@@ -4,6 +4,7 @@ require_once __DIR__ . '/../classes/Base.php';
$dsn = getenv('HCR_TEST_DSN');
if (!$dsn) throw new RuntimeException('Set HCR_TEST_DSN to an isolated test server.');
$db = new PDO($dsn, getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$db->exec('SET NAMES utf8mb4');
$action = $argv[1] ?? '';
$name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6)));
if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.');
@@ -22,11 +23,60 @@ if ($action === 'create') {
putenv('HCR_DB_NAME=' . $name);
require __DIR__ . '/../bin/migrate-auth.php';
require __DIR__.'/../bin/migrate-production.php';
putenv('HCR_SUPER_ADMIN_EMAIL=admin@example.test');
require __DIR__.'/../bin/migrate-tenants.php';
putenv('HCR_SUPER_ADMIN_EMAIL');
require __DIR__.'/../bin/migrate-security-capacity.php';
// Idempotent migration is verified separately by the HTTP test runner.
echo json_encode(['database' => $name]);
exit;
}
$db->exec("USE `{$name}`");
if($action==='memberSummarySeed'){
$a=(int)$argv[3];$b=(int)$argv[4];$own=(int)$argv[5];$neighbor=(int)$argv[6];$foreign=(int)$argv[7];$first=0;
$season=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');
$due=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,1)');
for($i=1;$i<=31;$i++){$season->execute([$a,'Member season '.sprintf('%02d',$i),'2026-01-01','2026-12-31']);$due->execute([$a,$own,(int)$db->lastInsertId(),10000]);if(!$first)$first=(int)$db->lastInsertId();}
$pay=$db->prepare("INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,payment_method,payment_reference,recorded_by) VALUES(?,100,'2026-01-01',?,'cash',?,1)");
for($i=1;$i<=32;$i++)$pay->execute([$first,bin2hex(random_bytes(32)),'OWN-RECEIPT-'.sprintf('%02d',$i)]);
$reversed=(int)$db->lastInsertId();
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,request_key,recorded_by) VALUES(?,?,?,1)');$stmt->execute([$reversed,'Own reversed receipt',bin2hex(random_bytes(32))]);
$stmt=$db->prepare('INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,request_key,recorded_by) VALUES(?,5000,15000,?,?,1)');$stmt->execute([$first,'Own correction <script>unsafe</script>',bin2hex(random_bytes(32))]);
foreach([[$a,$neighbor,'NEIGHBOR-PRIVATE'],[$b,$foreign,'FOREIGN-PRIVATE']] as [$club,$membership,$marker]){$season->execute([$club,'Additional season '.$membership,'2026-01-01','2026-12-31']);$due->execute([$club,$membership,(int)$db->lastInsertId(),99999]);$pay->execute([(int)$db->lastInsertId(),bin2hex(random_bytes(32)),$marker]);}
$season->execute([$a,'Available new season','2026-01-01','2026-12-31']);
echo '{}';exit;
}
if($action==='sessionAge'){
$id=$argv[3]??'';if(!preg_match('/\A[a-zA-Z0-9,-]{16,128}\z/',$id))throw new RuntimeException('Invalid test session.');
session_name('HUNTINGCLUBROSTER');session_id($id);session_start();
if(!isset($_SESSION[Base::USER_SERIAL]))throw new RuntimeException('Missing test session.');
foreach(['authenticated_at','last_activity','recent_auth'] as $index=>$field){$age=$argv[4+$index]??'';if($age!=='')$_SESSION[$field]=time()-(int)$age;}
session_write_close();echo '{}';exit;
}
if($action==='clearTestAttempts'){$db->exec('DELETE FROM auth_attempts');echo '{}';exit;}
if($action==='mfaTestSession'){
$id=$argv[3]??'';if(!preg_match('/\A[a-zA-Z0-9,-]{16,128}\z/',$id))throw new RuntimeException('Invalid test session.');
session_name('HUNTINGCLUBROSTER');session_id($id);session_start();
$user=(int)($_SESSION[Base::USER_SERIAL]??0);$stmt=$db->prepare('SELECT 1 FROM users WHERE user_serial=?');$stmt->execute([$user]);if(!$stmt->fetchColumn())throw new RuntimeException('Owned test account required.');
if(($argv[4]??'')==='expire'&&isset($_SESSION['mfa_replacement']))$_SESSION['mfa_replacement']['expires']=time()-1;
$state=$_SESSION;session_write_close();echo json_encode($state);exit;
}
if($action==='mfaTestAuditFailure'){
if(($argv[3]??'')==='on')$db->exec("CREATE TRIGGER reject_mfa_test_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
else $db->exec('DROP TRIGGER IF EXISTS reject_mfa_test_audit');echo '{}';exit;
}
if($action==='mfaResetToken'){
$token=bin2hex(random_bytes(32));$stmt=$db->prepare('INSERT INTO password_resets(token_hash,user_serial,expires_at) VALUES(?,1,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 HOUR))');$stmt->execute([hash('sha256',$token)]);echo json_encode(['token'=>$token]);exit;
}
if($action==='mfaState'){
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_email=?');$stmt->execute([$argv[3]]);$user=(int)$stmt->fetchColumn();
$stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=?');$stmt->execute([$user]);$factor=$stmt->fetch();
$stmt=$db->prepare('SELECT code_hash FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]);echo json_encode(['factor'=>$factor,'recovery'=>$stmt->fetchAll(PDO::FETCH_COLUMN)]);exit;
}
if($action==='mfaCode'){
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_email=?');$stmt->execute([$argv[3]]);$user=(int)$stmt->fetchColumn();
$code=bin2hex(random_bytes(16));$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$stmt->execute([$user,hash('sha256',$code)]);echo json_encode(['code'=>$code]);exit;
}
if($action==='fingerprints'){
$result=[];foreach($db->query('SHOW FULL TABLES WHERE Table_type="BASE TABLE"')->fetchAll(PDO::FETCH_COLUMN) as $table){
$rows=array_map(static fn($row)=>json_encode($row,JSON_THROW_ON_ERROR),$db->query('SELECT * FROM `'.str_replace('`','``',$table).'`')->fetchAll(PDO::FETCH_ASSOC));sort($rows,SORT_STRING);$result[$table]=hash('sha256',implode("\n",$rows));
@@ -36,11 +86,46 @@ if($action==='verifyEmail'){
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_name=?');$stmt->execute([$argv[3]??'']);echo json_encode(['verified'=>true]);exit;
}
if($action==='inspect'){
$tables=['users','dropdowntypes','dropdowns','statuses','notes','journal','clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals'];$result=[];
$tables=['users','dropdowntypes','dropdowns','statuses','notes','journal','clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','club_invitations','club_tickets','club_ticket_messages'];$result=[];
foreach($tables as $table)$result[$table]=$db->query('SELECT * FROM '.$table)->fetchAll(PDO::FETCH_ASSOC);
foreach($result['users'] as &$user)unset($user['user_password_hash']);unset($user);
echo json_encode($result);exit;
}
if($action==='legacyTenantSchema'){$db->exec('ALTER TABLE club_memberships DROP COLUMN membership_role');$db->exec('ALTER TABLE clubs DROP INDEX club_name, ADD UNIQUE KEY club_name (club_name)');echo '{}';exit;}
if($action==='restoreTestSuper'){$db->exec("UPDATE users SET user_status='active' WHERE user_serial=1");echo '{}';exit;}
if($action==='seedTenantInvitation'){
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invited_by,invite_email,expires_at,sent_at) VALUES(?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP())");$stmt->execute([(int)$argv[3],(int)$argv[4],$argv[5]]);echo json_encode(['invitation'=>(int)$db->lastInsertId()]);exit;
}
if($action==='demoteTenantAdmin'){
$stmt=$db->prepare("UPDATE club_memberships SET membership_role='member' WHERE membership_serial=?");$stmt->execute([(int)($argv[3]??0)]);echo '{}';exit;
}
if($action==='repairConcurrencySeed'){
$club=(int)$argv[3];$user=(int)$argv[4];$inviter=(int)$argv[5];
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,'inactive','2026-01-01')");$stmt->execute([$club,$user]);$membership=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,invited_by,expires_at,sent_at) SELECT ?,user_email,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP() FROM users WHERE user_serial=?");$stmt->execute([$club,$inviter,$user]);echo json_encode(['membership'=>$membership,'invitation'=>(int)$db->lastInsertId()]);exit;
}
if($action==='ticketPagingSeed'){
$a=(int)$argv[3];$b=(int)$argv[4];$owner=(int)$argv[5];$neighbor=(int)$argv[6];$foreign=(int)$argv[7];$oldest=0;
$stmt=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');
for($n=1;$n<=101;$n++){$stmt->execute([$a,$owner,'Archive ticket '.sprintf('%03d',$n)]);if($n===1)$oldest=(int)$db->lastInsertId();}
$stmt->execute([$a,$owner,'Literal %_! <b>needle</b>']);$literal=(int)$db->lastInsertId();
$stmt->execute([$a,$neighbor,'Private neighbor ticket']);$private=(int)$db->lastInsertId();
$stmt->execute([$b,$foreign,'Archive foreign club ticket']);$foreignTicket=(int)$db->lastInsertId();
echo json_encode(['oldest'=>$oldest,'literal'=>$literal,'private'=>$private,'foreign'=>$foreignTicket]);exit;
}
if($action==='tenantSeed'){
$ids=[];$hash=password_hash('Tenant test password 123!',PASSWORD_DEFAULT);
$insert=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role,user_email_verified_at) VALUES(?,?,?,?,?,?,UTC_TIMESTAMP())");
foreach(['admina','admina2','adminb','membera','memberb','shared','invitee','unverified'] as $label){$insert->execute(['tenant'.$label,'Tenant',ucfirst($label),$label.'@example.test',$hash,in_array($label,['admina','adminb'],true)?'Administrator':'User']);$ids[$label]=(int)$db->lastInsertId();}
$db->exec("UPDATE users SET user_email_verified_at=NULL WHERE user_name='tenantunverified'");
$db->exec("INSERT INTO clubs(club_name) VALUES('Tenant Alpha')");$ids['clubA']=(int)$db->lastInsertId();$db->exec("INSERT INTO clubs(club_name) VALUES('Tenant Beta')");$ids['clubB']=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,joined_on) VALUES(?,?,?,'2026-01-01')");
foreach([['clubA','admina','administrator'],['clubA','admina2','administrator'],['clubB','adminb','administrator'],['clubA','membera','member'],['clubB','memberb','member'],['clubA','shared','member'],['clubB','shared','member'],['clubB','admina','member']] as [$club,$who,$role]){$stmt->execute([$ids[$club],$ids[$who],$role]);$ids[$club.'_'.$who]=(int)$db->lastInsertId();}
echo json_encode($ids);exit;
}
if($action==='maintenance'){
$out=[];foreach(['alerts','popovers'] as $table)$out[$table]=$db->query('SELECT * FROM '.$table.' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);echo json_encode($out);exit;
}
if($action==='unsafeRule'){
$db->exec("INSERT INTO dropdowntypes(dropdowntype_name,dropdowntype_title,dropdowntype_table,dropdowntype_column) VALUES('Unsafe legacy','Unsafe legacy','*function','getSettings')");$type=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO dropdowns(dropdown_dropdowntype,dropdown_value) VALUES(?,'Legacy')");$stmt->execute([$type]);echo json_encode(['dropdown'=>(int)$db->lastInsertId()]);exit;
+36
View File
@@ -0,0 +1,36 @@
from application_harness import Application
app=Application();checks=[]
def check(value,message):
if not value:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def valid(action,**fields):
result=app.post(action,**fields);check(result[0]==200,'Valid '+action+' succeeds: '+str(result[0]));return result
def invalid(action,fields,message):
before=app.fixture('fingerprints');response=app.post(action,**fields)
check(response[0]==422,message+' returns validation status')
check(app.fixture('fingerprints')==before,message+' leaves stored data unchanged')
try:
app.login()
alert=dict(step='add',alert_title='Valid alert',alert_starts_date='2026-10-20',alert_starts_time='12:00',alert_expires_date='2026-10-21',alert_expires_time='12:00',alert_message='<p>Valid text</p>')
for change,label in [({'alert_title':' '},'Blank alert title'),({'alert_title':'x'*101},'Oversized alert title'),({'alert_message':'x'*1001},'Oversized alert HTML'),({'alert_starts_date':'2026-02-30'},'Invalid calendar date'),({'alert_starts_time':'25:00'},'Invalid hour'),({'alert_starts_date':'10/20/2026'},'Ambiguous date format'),({'alert_expires_date':'2026-10-19'},'Reversed alert interval'),({'alert_title':'Line\nBreak'},'Multiline title'),({'alert_starts_date':'2026-03-08','alert_starts_time':'02:30'},'Nonexistent daylight-saving time')]:
invalid('Alerts.addAlert',{**alert,**change},label)
valid('Alerts.addAlert',**alert);ident=app.fixture('maintenance')['alerts'][0]['alert_serial']
invalid('Alerts.editAlert',{**alert,'step':'update','alert_serial':ident,'alert_title':''},'Invalid alert edit')
invalid('Alerts.editAlert',{**alert,'step':'update','alert_serial':'1 OR 1=1'},'Malformed alert edit ID')
valid('Alerts.editAlert',**{**alert,'step':'update','alert_serial':ident,'alert_title':'Updated alert','alert_message':'<table><tbody><tr><td>Preserved</td></tr></tbody></table>'})
stored=app.fixture('maintenance')['alerts'][0];check(stored['alert_title']=='Updated alert' and '<table>' in stored['alert_message'],'Valid edit preserves rich HTML without truncation')
valid('Alerts.addAlert',**{**alert,'alert_title':'界'*100,'alert_starts_date':'2028-02-29','alert_expires_date':'2028-03-01'})
stored_title=app.fixture('maintenance')['alerts'][-1]['alert_title']
check(stored_title=='界'*100,'Unicode limit counts characters rather than bytes')
popover=dict(step='add',popover_name='Valid popover',popover_title='Title',popover_placeholder='Optional hint',popover_text='<p>Text</p>')
for change,label in [({'popover_name':''},'Blank popover name'),({'popover_title':' '},'Blank popover title'),({'popover_name':'x'*101},'Oversized popover name'),({'popover_title':'x'*101},'Oversized popover title'),({'popover_placeholder':'x'*101},'Oversized placeholder'),({'popover_text':'x'*1001},'Oversized popover HTML')]:
invalid('Popovers.addPopover',{**popover,**change},label)
valid('Popovers.addPopover',**popover);ident=app.fixture('maintenance')['popovers'][0]['popover_serial']
invalid('Popovers.editPopover',{**popover,'step':'update','popover_serial':ident,'popover_title':''},'Invalid popover edit')
invalid('Popovers.editPopover',{**popover,'step':'update','popover_serial':'1 OR 1=1'},'Malformed popover edit ID')
valid('Popovers.editPopover',**{**popover,'step':'update','popover_serial':ident,'popover_title':'Updated title','popover_text':'<p><strong>Updated</strong></p>'})
check(app.fixture('maintenance')['popovers'][0]['popover_text']=='<p><strong>Updated</strong></p>','Popover editing retains formatting')
response=app.post('Popovers.popoverExists',popover_serial=0,popover_name='Valid popover');check(response[0]==200 and response[1]==b'true','Create-form uniqueness check still accepts its zero exclusion ID')
finally:app.close()
print(str(len(checks))+' maintenance HTTP checks passed.')
+4 -2
View File
@@ -3,11 +3,13 @@ if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();$name=$argv[2]??'';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name)||(string)$settings->DatabaseName!==$name||!in_array((string)$settings->DatabaseServer,['127.0.0.1','localhost','::1'],true))throw new RuntimeException('Concurrency workers require an isolated local test database.');
$_SESSION=[Base::USER_AUTHENTICATED=>true,Base::USER_ROLE=>'Administrator',Base::USER_SERIAL=>1,Base::USER_NAME=>'Test worker'];
$_SESSION=[Base::USER_AUTHENTICATED=>true,Base::USER_ROLE=>TenantAccess::SUPER,Base::USER_SERIAL=>1,Base::USER_NAME=>'Test worker'];
echo "READY\n";flush();fgets(STDIN);
try{
$service=new MaintenanceService();
if(($argv[1]??'')==='disable')$service->setUserActive((int)$argv[3],false);
if(($argv[1]??'')==='accept'){$_SESSION[Base::USER_SERIAL]=(int)$argv[4];(new ClubInvitations())->accept((int)$argv[3]);}
elseif(($argv[1]??'')==='membership')(new RosterService())->saveMember((int)$argv[4],(int)$argv[3],'','inactive','2026-01-01');
elseif(($argv[1]??'')==='disable')$service->setUserActive((int)$argv[3],false);
elseif(($argv[1]??'')==='default')$service->setDropdownDefault((int)$argv[3],(int)$argv[4],true);
elseif(($argv[1]??'')==='payment')(new LedgerService())->payment((int)$argv[4],(int)$argv[3],$argv[5],'2026-01-01',bin2hex(random_bytes(32)));
elseif(($argv[1]??'')==='reverse')(new LedgerService())->reverse((int)$argv[4],(int)$argv[3],'Concurrent reversal',$argv[5]);
+64
View File
@@ -0,0 +1,64 @@
import json,secrets,urllib.request,urllib.parse,urllib.error
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
request=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(request) as response:return response.status,json.loads(response.read())
except urllib.error.HTTPError as error:
body=error.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return error.code,result
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];neighbor=ids['clubA_shared'];foreign=ids['clubB_shared']
app.login()
for club,name in [(a,'New season'),(a,'Other season'),(b,'Foreign season')]:app.post('Roster.workspace',step='season',club_serial=club,season_name=name,starts_on='2026-01-01',ends_on='2026-12-31')
state=app.fixture('inspect');season=next(s['season_serial'] for s in state['club_seasons'] if s['season_name']=='New season');foreignseason=next(s['season_serial'] for s in state['club_seasons'] if s['season_name']=='Foreign season')
app.post('Roster.workspace',step='charge',club_serial=a,membership_serial=neighbor,season_serial=season,amount='100.00')
app.post('Roster.workspace',step='charge',club_serial=b,membership_serial=foreign,season_serial=foreignseason,amount='100.00')
state=app.fixture('inspect');neighbor_due=state['club_dues'][0]['due_serial'];foreign_due=state['club_dues'][1]['due_serial']
app.logout();app.login('admina@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member))
check(b'data-club-step="member"' in body and b'data-club-step="charge"' in body,'Club administrator gets member edit and dues dialogs')
check(save('charge',a,member,csrf_token='invalid',season_serial=season,amount='100.00')[0]==403,'Member summary writes require a valid CSRF token')
check(save('charge',a,member,membership_serial=neighbor,season_serial=season,amount='100.00')[0]==409,'Posted membership cannot differ from the selected summary')
status,result=save('charge',a,member,season_serial=season,amount='100.00')
check(status==200 and result['ok'] and 'Roster.memberSummary' in result['redirect'] and 'membership_serial='+str(member) in result['redirect'],'Dues save returns a same-member summary redirect')
state=app.fixture('inspect');due=next(d['due_serial'] for d in state['club_dues'] if d['membership_serial']==member)
check(save('charge',a,member,season_serial=season,amount='100.00')[0]==409,'Duplicate season dues remain rejected')
check(save('charge',a,member,season_serial=foreignseason,amount='10.00')[0]==409,'Dues cannot use another club season')
check(save('payment',a,member,due_serial=neighbor_due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==404,'Payment cannot target a different member in the same club')
check(save('payment',a,member,due_serial=foreign_due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==404,'Payment cannot target another club dues')
key=secrets.token_hex(32)
check(save('payment',a,member,due_serial=due,amount='25.00',paid_on='2026-01-01',request_key=key,payment_method='cash',payment_reference='Member summary receipt')[0]==200,'Payment is recorded for the selected membership')
check(save('payment',a,member,due_serial=due,amount='25.00',paid_on='2026-01-01',request_key=key,payment_method='cash',payment_reference='Member summary receipt')[0]==200 and len(app.fixture('inspect')['club_payments'])==1,'Payment retry remains idempotent')
app.fixture('mfaTestAuditFailure','on')
status,_=save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))
app.fixture('mfaTestAuditFailure','off')
check(status==500 and len(app.fixture('inspect')['club_payments'])==1,'Failed mandatory audit rolls back a summary payment completely')
check(save('payment',a,member,due_serial=due,amount='76.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==409,'Overpayments remain rejected')
status,result=save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-02-01')
check(status==200 and next(m for m in app.fixture('inspect')['club_memberships'] if m['membership_serial']==member)['joined_on']=='2026-02-01','Membership information saves from the summary')
check(save('member',a,member,membership_status='active',membership_role='administrator',joined_on='2026-02-01')[0]==403,'Club administrator cannot promote members to administrator')
check(save('member',a,ids['clubA_admina2'],membership_status='inactive',membership_role='member',joined_on='2026-02-01')[0]==403,'Club administrator cannot edit protected administrator memberships')
sid=next(c.value for h in app.client.handlers if hasattr(h,'cookiejar') for c in h.cookiejar if c.name=='HUNTINGCLUBROSTER');app.fixture('sessionAge',sid,'','','301')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Membership edits require recent MFA on the new endpoint')
app.logout();app.login('membera@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Ordinary member receives no action dialogs')
check(save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a summary payment request')
check(save('charge',a,member,season_serial=season,amount='1.00')[0]==403,'Ordinary member cannot forge a dues request')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Other club administrator cannot modify the membership')
app.logout();app.login();app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Archived club summary exposes no write dialogs')
check(save('charge',a,member,season_serial=season,amount='10.00')[0]==409,'Archived club rejects forged member summary writes')
print(checks,'member action HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+48
View File
@@ -0,0 +1,48 @@
import re
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def summary(club,membership,extra=''):
return app.request('/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(membership)+extra)
try:
ids=app.fixture('tenantSeed');app.fixture('memberSummarySeed',str(ids['clubA']),str(ids['clubB']),str(ids['clubA_membera']),str(ids['clubA_shared']),str(ids['clubB_shared']))
_,login,_=app.request()
app.request(data={'action':'SignIn.login','csrf_token':app.csrf(login),'user_email':'admin@example.test','user_password':'Admin test password 123!'})
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'<h1>Account security</h1>' in body and b'<h1>Tenant Membera</h1>' not in body,'Member summary cannot bypass mandatory administrator MFA')
check(app.request('/main.php?Xaction=Roster.memberSummary&club_serial='+str(ids['clubA'])+'&membership_serial='+str(ids['clubA_membera']),ajax=True)[0]==403,'AJAX member summary also requires completed administrator MFA')
app.login();before=app.fixture('fingerprints')
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'<h1>Tenant Membera</h1>' in body and b'membera@example.test' in body,'Super Administrator opens a club member summary')
check(b'$3,150.00' in body and b'$31.00' in body and b'$3,119.00' in body,'Totals include all dues corrections and exclude reversed payments across every page')
check(b'Own reversed receipt' in body and b'OWN-RECEIPT-32' in body,'Payment history preserves reversed receipts and their reasons')
check(b'&lt;script&gt;unsafe&lt;/script&gt;' in body and b'<script>unsafe</script>' not in body,'Correction reasons are escaped safely')
check(b'NEIGHBOR-PRIVATE' not in body and b'FOREIGN-PRIVATE' not in body,'Selected member summary excludes neighboring members and other clubs')
check(body.count(b'OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently')
links=re.findall(rb'href="([^"]+)"',body)
check(any(b'payments_page=2' in link and b'membership_serial='+str(ids['clubA_membera']).encode() in link and b'Roster.memberSummary' in link for link in links),'History pager links retain both club and membership scope')
status,last,_=summary(ids['clubA'],ids['clubA_membera'],'&payments_page=9999&dues_page=9999')
check(status==200 and last.count(b'OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records')
check(b'$3,150.00' in last and b'$31.00' in last and b'$3,119.00' in last,'Summary totals remain identical on later history pages')
check(summary(ids['clubB'],ids['clubA_membera'])[0]==404,'Membership IDs cannot be paired with another club')
check(summary(ids['clubA'],'invalid')[0]==422,'Invalid membership identifiers are rejected')
check(summary(ids['clubA'],ids['clubA_membera'],'&payments_page=0')[0]==422,'Invalid history pages are rejected')
check(app.fixture('fingerprints')==before,'Opening summaries and changing pages perform no database writes beyond authentication bookkeeping')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(summary(ids['clubA'],ids['clubA_membera'])[0]==200,'Club administrator can view a member in the administered club')
check(summary(ids['clubB'],ids['clubB_shared'])[0]==404,'Administrator cannot view another member where they are only an ordinary member')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(summary(ids['clubA'],ids['clubA_membera'])[0]==403,'Other club administrator is denied access to the club summary')
app.logout();app.login('membera@example.test','Tenant test password 123!')
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'$3,119.00' in body and b'club-action-modal' not in body,'Member can read their own finances without administrator actions')
check(summary(ids['clubA'],ids['clubA_shared'])[0]==404,'Member cannot read another member summary in the same club')
check(summary(ids['clubB'],ids['clubB_shared'])[0]==403,'Member cannot read another club summary')
app.logout();app.login('shared@example.test','Tenant test password 123!')
_,a,_=summary(ids['clubA'],ids['clubA_shared']);_,b,_=summary(ids['clubB'],ids['clubB_shared'])
check(b'NEIGHBOR-PRIVATE' in a and b'FOREIGN-PRIVATE' not in a and b'FOREIGN-PRIVATE' in b and b'NEIGHBOR-PRIVATE' not in b,'A member of two clubs sees separate membership histories')
print(checks,'member summary HTTP checks passed.')
finally:app.close()
+34
View File
@@ -0,0 +1,34 @@
import json,subprocess
from application_harness import Application,ROOT,PHP
app=Application();workers=[]
def race(operation,seed):
group=[]
for state in seed['states']:
p=subprocess.Popen(PHP+['tests/mfa_worker.php',operation],cwd=ROOT,env=app.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
workers.append(p);group.append(p)
p.stdin.write(json.dumps({'state':state,'recovery':seed['recovery']})+'\n');p.stdin.flush()
for p in group:assert p.stdout.readline().strip()=='READY'
for p in group:p.stdin.write('go\n');p.stdin.flush()
result=[]
for p in group:
out,err=p.communicate(timeout=30)
assert p.returncode==0,err
result.append(json.loads(out))
return result
try:
seed=json.loads(subprocess.check_output(PHP+['tests/mfa_worker.php','seed'],cwd=ROOT,env=app.env,text=True))
before=app.fixture('mfaState','admin@example.test')
result=race('verify',seed)
after=app.fixture('mfaState','admin@example.test')
assert sorted(r['ok'] for r in result)==[False,True] and len(after['recovery'])==len(before['recovery'])-1,result
print('PASS: Concurrent recovery attempts consume a single-use code exactly once.')
result=race('replace',seed)
after=app.fixture('mfaState','admin@example.test')
assert sorted(r['ok'] for r in result)==[False,True],result
assert len(after['recovery'])==10 and after['factor']['secret_cipher']!=before['factor']['secret_cipher']
print('PASS: Competing authorized replacements commit exactly one complete factor swap.')
print('2 MFA concurrency checks passed.')
finally:
for p in workers:
if p.poll() is None:p.kill();p.wait()
app.close()
+33
View File
@@ -0,0 +1,33 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
class FileKeyMfa extends MfaService {
public string $environment='Development';
public string $file='';
public function getSettings(){
$settings=new SimpleXMLElement('<Settings/>');$settings->Environment=$this->environment;$settings->MfaKeyFile=$this->file;return $settings;
}
}
$old=getenv('HCR_MFA_KEY');$oldFile=getenv('HCR_MFA_KEY_FILE');putenv('HCR_MFA_KEY');putenv('HCR_MFA_KEY_FILE');$checks=0;$file=tempnam(sys_get_temp_dir(),'hcr-mfa-key-');$inside=__DIR__.'/.local/key-path-test-'.bin2hex(random_bytes(6));
function keyCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
function keyDenied(callable $work,string $message):void{try{$work();}catch(RuntimeException $e){keyCheck(true,$message);return;}throw new LogicException($message.' was allowed');}
try{
$service=new FileKeyMfa();$service->file=$file;file_put_contents($file,base64_encode(random_bytes(32)));
keyCheck($service->sealSecret('test',1)!=='','Development loads a persistent key outside the application');
$service->file=$file.'.missing';putenv('HCR_MFA_KEY_FILE='.$file);
keyCheck($service->sealSecret('test',1)!=='','Server key-file configuration overrides workstation settings');
$service->environment='Production';keyDenied(fn()=>$service->sealSecret('test',1),'Production cannot use the development key-file fallback');
putenv('HCR_MFA_KEY_FILE');$service->file=$file;
putenv('HCR_MFA_KEY='.base64_encode(random_bytes(32)));keyCheck($service->sealSecret('test',1)!=='','Explicit production environment key remains supported');
putenv('HCR_MFA_KEY=invalid');$service->environment='Development';keyDenied(fn()=>$service->sealSecret('test',1),'Invalid explicit key is not silently replaced by a file');putenv('HCR_MFA_KEY');
if(!is_dir(dirname($inside)))mkdir(dirname($inside),0700,true);file_put_contents($inside,base64_encode(random_bytes(32)));$service->file=$inside;
keyDenied(fn()=>$service->sealSecret('test',1),'Development key inside the application tree is rejected');
$service->file=$file;file_put_contents($file,str_repeat('A',129));keyDenied(fn()=>$service->sealSecret('test',1),'Oversized key file is rejected');
file_put_contents($file,'invalid');keyDenied(fn()=>$service->sealSecret('test',1),'Malformed file key is rejected');
$service->file=$file.'.missing';keyDenied(fn()=>$service->sealSecret('test',1),'Missing development key file is rejected');
echo "$checks MFA key configuration checks passed.\n";
}finally{
if(is_file($file))unlink($file);if(is_file($inside))unlink($inside);
if($old===false)putenv('HCR_MFA_KEY');else putenv('HCR_MFA_KEY='.$old);
if($oldFile===false)putenv('HCR_MFA_KEY_FILE');else putenv('HCR_MFA_KEY_FILE='.$oldFile);
}
+86
View File
@@ -0,0 +1,86 @@
"""Competing password-only sessions must never inherit replacement permission."""
import http.cookiejar,re,time,urllib.error,urllib.parse,urllib.request
from application_harness import Application
app=Application();checks=0;password='Admin test password 123!'
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar();self.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar));self.body=b''
self.login()
def request(self,path='/',data=None,ajax=False):
req=urllib.request.Request(app.base+path,data=urllib.parse.urlencode(data).encode() if data is not None else None,headers={'X-Requested-With':'XMLHttpRequest'} if ajax else {})
try:
with self.client.open(req,timeout=30) as r:status,body=r.status,r.read()
except urllib.error.HTTPError as e:status,body=e.code,e.read()
if b'csrf' in body:self.body=body
return status,body
def login(self,pwd=password):
_,body=self.request();return self.request(data={'action':'SignIn.login','csrf_token':app.csrf(body),'user_email':'admin@example.test','user_password':pwd})
def security(self,code='',pwd=password,**extra):
return self.request(data={'action':'security','csrf_token':app.csrf(self.body),'current_password':pwd,'mfa_code':code,**extra})
def post(self,action,**extra):return self.request(data={'action':action,'csrf_token':app.csrf(self.body),**extra},ajax=True)
def state(self,operation='snapshot'):
sid=next(c.value for c in self.jar if c.name=='HUNTINGCLUBROSTER');return app.fixture('mfaTestSession',sid,operation)
def factor():return app.fixture('mfaState','admin@example.test')
def secret(body):return re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
try:
victim=Client();original_secret=secret(victim.body)
status,body=victim.security(app.totp(original_secret));codes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(codes)==10,'Initial enrollment succeeds')
check(b'notification email could not be sent' in body,'Failed security email is visibly reported without undoing enrollment')
victim.request('/main.php?Xaction=security');before=factor()
status,_=victim.security(codes[0],pwd='incorrect')
check(status==403 and factor()==before,'Wrong password preserves the recovery code and factor counter')
status,_=victim.security(codes[0]);check(status==200 and len(factor()['recovery'])==9,'Recovery code remains usable with the correct password')
victim.request('/main.php?Xaction=security');before=factor();next_code=app.totp(original_secret,time.time()+30)
status,_=victim.security(next_code,pwd='incorrect');check(status==403 and factor()==before,'Wrong password cannot consume an authenticator time step')
status,_=victim.security(next_code);check(status==200,'Authenticator time step still verifies with the correct password')
victim.request('/main.php?Xaction=security');before=factor();version=victim.state()['auth_version']
status,body=victim.security(codes[1],factor_action='replace');replacement_secret=secret(body)
check(status==200 and factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Starting replacement keeps the old factor and unused recovery codes')
check(victim.state()['auth_version']==version,'Starting replacement does not revoke other sessions prematurely')
attacker=Client();check(b'setup key' not in attacker.body and b'Authentication code' in attacker.body,'A separate password-only session still faces the existing MFA challenge')
status,_=attacker.security(app.totp(replacement_secret),factor_action='confirmReplacement')
check(status==409 and factor()['factor']==before['factor'],'Password-only session cannot confirm another session\'s pending replacement')
check(attacker.post('Users.setupUsers')[0]==403,'Password-only session cannot access Super Administrator pages')
victim.security(factor_action='cancelReplacement')
check(factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Canceling replacement preserves the existing factor and remaining codes')
victim.security(codes[2],factor_action='replace');expired_secret=secret(victim.body);victim.state('expire');before=factor()
status,_=victim.security(app.totp(expired_secret),factor_action='confirmReplacement')
check(status==409 and factor()==before,'Expired replacement cannot change either factor or recovery codes')
app.fixture('clearTestAttempts')
victim.security(codes[3],factor_action='replace');replacement_secret=secret(victim.body);before=factor()
status,_=victim.security(app.totp(replacement_secret),pwd='incorrect',factor_action='confirmReplacement')
check(status==403 and factor()==before,'Replacement confirmation with wrong password is non-mutating')
app.fixture('clearTestAttempts')
status,_=victim.security('not-a-code',factor_action='confirmReplacement')
check(status==422 and factor()==before,'Invalid new authenticator code leaves existing protection intact')
app.fixture('mfaTestAuditFailure','on')
status,_=victim.security(app.totp(replacement_secret),factor_action='confirmReplacement')
app.fixture('mfaTestAuditFailure','off')
check(status==500 and factor()==before,'Failed mandatory audit rolls back the entire factor swap')
victim.request('/main.php?Xaction=security')
confirmed_code=app.totp(replacement_secret)
status,body=victim.security(confirmed_code,factor_action='confirmReplacement');newcodes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(newcodes)==10 and factor()['factor']['secret_cipher']!=before['factor']['secret_cipher'],'Successful confirmation swaps the factor and creates ten fresh recovery codes')
check(not set(before['recovery'])&set(factor()['recovery']),'Confirmation revokes every old recovery code')
check(attacker.post('Users.setupUsers')[0]==401,'Successful replacement revokes the competing session')
victim.request('/main.php?Xaction=security');before=factor();status,_=victim.security(confirmed_code)
check(status==403 and factor()==before,'New authenticator confirmation code cannot be replayed')
status,_=victim.security(newcodes[0]);check(status==200,'New recovery codes authenticate successfully')
app.fixture('clearTestAttempts');victim.request('/main.php?Xaction=security');victim.security(newcodes[1],factor_action='replace');before=factor()
token=app.fixture('mfaResetToken')['token'];newpwd='Changed with MFA still active 123!'
_,reset=victim.request('/main.php?Xaction=resetpassword&token='+token)
status,_=victim.request(data={'action':'resetpassword','step':'reset','csrf_token':app.csrf(reset),'token':token,'user_password':newpwd,'user_password_confirm':newpwd})
check(status==200 and factor()==before,'Password reset preserves the active factor during pending replacement')
check(victim.post('security',factor_action='confirmReplacement',current_password=newpwd,mfa_code='000000')[0]==401,'Password reset invalidates the old replacement session')
fresh=Client();fresh.login(newpwd)
check(b'Authentication code' in fresh.body and b'setup key' not in fresh.body,'Password reset never reopens password-only MFA enrollment')
print(checks,'MFA lifecycle HTTP checks passed.')
except Exception:
app.log.seek(0);print(app.log.read().decode(errors='replace')[-4500:]);raise
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+45
View File
@@ -0,0 +1,45 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
ob_start();session_start();$db=(new Base())->connect();$checks=0;
function notificationCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
class CapturedMfaNotice extends MfaSecurityNotifications {
public array $messages=[];public bool $works=true;public bool $throws=false;
protected function deliver(string $to,string $subject,string $body):bool {
if($this->throws)throw new RuntimeException('Test delivery failure');
$this->messages[]=compact('to','subject','body');return $this->works;
}
}
$notice=new CapturedMfaNotice();$account=['user_serial'=>1,'user_email'=>'admin+security@example.test'];
foreach(['enrolled','replacement_started','replacement_started_recovery','replaced','recovery_used'] as $event){
notificationCheck($notice->send($account,$event),'Security notice supports event '.$event);
$message=end($notice->messages);notificationCheck($message['to']===$account['user_email']&&str_contains($message['body'],'Time (UTC)')&&str_contains($message['body'],'did not authorize'),'Notice uses stored address, time and unauthorized-activity instructions');
}
notificationCheck(!str_contains(implode('',array_column($notice->messages,'body')),'otpauth://')&&!str_contains(implode('',array_column($notice->messages,'body')),'secret='),'Security notices contain no provisioning links or setup secrets');
$notice->send(['user_serial'=>1,'user_email'=>'<img src=x onerror=alert(1)>'],'enrolled');notificationCheck(!str_contains(end($notice->messages)['body'],'<img'),'Account text is escaped in security email');
$notice->works=false;notificationCheck(!$notice->send($account,'enrolled'),'Delivery failure is reported to the caller');$notice->throws=true;notificationCheck(!$notice->send($account,'replaced'),'Provider exceptions cannot undo committed security actions');
try{$notice->send($account,'untrusted-event');throw new RuntimeException('Unknown event accepted');}catch(LogicException $e){notificationCheck(true,'Only reviewed security event types can be sent');}
$_SESSION['mfa_notification_warning']=true;notificationCheck(str_contains(MfaSecurityNotifications::notice(),'notification email could not be sent')&&MfaSecurityNotifications::notice()==='','Notification failure warning is visible and consumed once');
class CapturedMfaEvents extends MfaService {
public array $events=[];
protected function notify(array $account,string $event):void {
if($this->connect()->inTransaction())throw new RuntimeException('Notification attempted before commit');
$this->events[]=$event;
}
}
$auth=new AuthService();$auth->login('admin@example.test','Admin test password 123!');$mfa=new CapturedMfaEvents();$user=1;
$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();$_SESSION['mfa_pending']=['user'=>1,'secret'=>$secret,'expires'=>time()+600];
$codes=$mfa->enroll($user,$secret,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');
notificationCheck($mfa->events===['enrolled'],'Enrollment notification runs once after transaction commit');
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'wrong')&&$before===$mfa->events,'Failed password creates no recovery notification');
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
try{$mfa->verify(1,$codes[0],'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back recovery does not send a false security notice');}$db->exec('DROP TRIGGER reject_notice_audit');
notificationCheck($mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===['enrolled','recovery_used'],'Committed recovery sends exactly one notice');
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===$before,'Replayed recovery sends no duplicate notice');
$mfa->beginReplacement(1,$codes[1],'Admin test password 123!');notificationCheck(end($mfa->events)==='replacement_started_recovery','Recovery-authorized replacement sends one combined security notice');
$secret=$_SESSION['mfa_replacement']['secret'];$before=$mfa->events;
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
try{$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back replacement sends no completion notice');}$db->exec('DROP TRIGGER reject_notice_audit');
$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');notificationCheck(count($mfa->events)===4&&end($mfa->events)==='replaced','Successful replacement sends one completion notice after commit');
echo "$checks MFA notification checks passed.\n";ob_end_flush();
+5
View File
@@ -0,0 +1,5 @@
import subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:subprocess.run(PHP+['tests/mfa_notifications.php'],cwd=ROOT,env=app.env,check=True)
finally:app.close()
+38
View File
@@ -0,0 +1,38 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const jsQR=require('jsqr');
const {totp}=require('./browser_mfa');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[],requests=[];let checks=0;
page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>requests.push(r.url()));
const pass=message=>{checks++;console.log('PASS:',message);};
const click=async button=>{await Promise.all([page.waitForNavigation(),button.click()]);};
async function scan(){
const image=page.locator('.mfa-setup-qr');await image.waitFor({state:'visible'});
const pixels=await image.evaluate(async img=>{
await img.decode();const canvas=document.createElement('canvas');canvas.width=canvas.height=320;
const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,320,320);
return Array.from(ctx.getImageData(0,0,320,320).data);
});
const decoded=jsQR(new Uint8ClampedArray(pixels),320,320,{inversionAttempts:'dontInvert'});
assert(decoded,'QR image did not decode');return new URL(decoded.data);
}
try{
await page.goto(process.env.HCR_TEST_URL+'/main.php');await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await click(page.locator('#login button[type=submit]'));
const image=page.locator('.mfa-setup-qr');assert.match(await image.getAttribute('src'),/^data:image\/svg\+xml;base64,/);pass('Setup renders a locally generated QR image under the security policy');
const uri=await scan(),secret=uri.searchParams.get('secret');assert.equal(uri.protocol,'otpauth:');assert.equal(uri.host,'totp');assert.equal(uri.searchParams.get('issuer'),'My Hunting Club Roster');assert(decodeURIComponent(uri.pathname).includes('admin@example.test'));pass('Independent decoder reads the correct authenticator type, issuer and account');
assert.equal(secret,(await page.locator('pre').textContent()).trim());assert.equal(uri.searchParams.get('period')||'30','30');assert.equal(uri.searchParams.get('digits')||'6','6');assert.equal((uri.searchParams.get('algorithm')||'SHA1').toUpperCase(),'SHA1');pass('QR and manual key configure the same secret and verification settings');
await page.reload();assert((await scan()).href===uri.href,'Refreshing setup changed its provisioning details');pass('Refreshing pending setup preserves its QR details and manual key');
const box=await image.boundingBox();assert(box.width<=390&&box.height===box.width);pass('QR image fits a phone-width browser without distortion');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(secret));await click(page.getByRole('button',{name:'Verify',exact:true}));
await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();const codes=(await page.locator('pre').textContent()).trim().split(/\s+/);assert.equal(codes.length,10);pass('A code generated from the scanned QR completes MFA enrollment');
assert.equal(await page.locator('.mfa-setup-qr').count(),0);await click(page.getByRole('link',{name:'I have saved my recovery codes'}));await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=security');assert.equal(await page.locator('.mfa-setup-qr').count(),0);pass('Enrolled accounts and recovery-code pages do not display the setup QR');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(codes[0]);await click(page.getByRole('button',{name:'Replace lost authenticator',exact:true}));
const replacement=await scan();assert.notEqual(replacement.searchParams.get('secret'),secret);assert.equal(replacement.searchParams.get('secret'),(await page.locator('pre').textContent()).trim());pass('Authenticator replacement gets a fresh QR matching the new manual key');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(replacement.searchParams.get('secret')));await click(page.getByRole('button',{name:'Confirm replacement',exact:true}));await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();pass('Scanned replacement code confirms the atomic factor swap');
assert(requests.every(url=>url.startsWith(process.env.HCR_TEST_URL)||url.startsWith('data:')));assert(!requests.some(url=>url.includes(secret)));pass('Setup makes no external QR requests and puts no secret in request URLs');
assert.deepEqual(errors,[]);pass('QR enrollment and replacement produce no browser script errors');
console.log(checks,'MFA QR browser checks passed.');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1);});
+8
View File
@@ -0,0 +1,8 @@
import os,subprocess
from application_harness import Application,ROOT
app=Application()
try:
env=app.env.copy();env['HCR_TEST_URL']=app.base
env['NODE_PATH']=str(ROOT/'.tools/qr-test/node_modules')+os.pathsep+env.get('NODE_PATH','')
subprocess.run([env.get('NODE_TEST_COMMAND','node'),'tests/mfa_qr_browser.js'],cwd=ROOT,env=env,check=True)
finally:app.close()
+27
View File
@@ -0,0 +1,27 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
ob_start();session_start();
class SilentRaceMfa extends MfaService {protected function notify(array $account,string $event):void {}}
$mfa=new SilentRaceMfa();$password='Admin test password 123!';
if(($argv[1]??'')==='seed'){
(new AuthService())->login('admin@example.test',$password);
$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();
$_SESSION['mfa_pending']=['user'=>1,'secret'=>$secret,'expires'=>time()+600];
$codes=$mfa->enroll(1,$secret,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),$password);
$base=$_SESSION;$states=[];
foreach([$codes[0],$codes[1]] as $code){$_SESSION=$base;$mfa->beginReplacement(1,$code,$password);$states[]=$_SESSION;}
ob_clean();echo json_encode(['states'=>$states,'recovery'=>$codes[2]]);ob_end_flush();exit;
}
$payload=json_decode(trim(fgets(STDIN)),true,512,JSON_THROW_ON_ERROR);$_SESSION=$payload['state'];
ob_clean();fwrite(STDOUT,"READY\n");fflush(STDOUT);fgets(STDIN);
try{
if($argv[1]==='replace'){
$secret=$_SESSION['mfa_replacement']['secret'];
$codes=$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),$password);
$ok=count($codes)===10;
}else $ok=$mfa->verify(1,$payload['recovery'],$password);
$result=['ok'=>$ok];
}catch(AppException $error){$result=['ok'=>false,'status'=>$error->status];}
ob_clean();echo json_encode($result);ob_end_flush();
+3 -3
View File
@@ -2,7 +2,7 @@
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;
$db=(new Base())->connect();$_SESSION[Base::USER_ROLE]=TenantAccess::SUPER;$_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);}
@@ -52,9 +52,9 @@ $db->exec('DELETE FROM auth_attempts');$verification->mailWorks=false;try{$verif
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();
$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]=TenantAccess::SUPER;$_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;
$_SESSION[Base::USER_ROLE]=TenantAccess::SUPER;$_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);
+3 -2
View File
@@ -97,8 +97,9 @@ try:
# Set a known member password in this disposable fixture via the real reset flow.
# Fixture token generation is intentionally scoped to httpuser, so create that
# account through signup and assign it separately for isolation tests.
app.logout();_,signup,_=app.request('/?Xaction=signup')
expect(app.request(data={'action':'SignUp.createAccount','csrf_token':app.csrf(signup),'user_first_name':'Regular','user_last_name':'Member','user_email':'http@example.test','user_name':'httpuser','user_password':'HTTP long password!','user_password_confirm':'HTTP long password!'}),200,'Regular member account can sign up')
app.fixture('seedTenantInvitation',str(club_a),'1','http@example.test')
app.logout();_,signup,_=app.request('/?Xaction=signup&membership=invited')
expect(app.request(data={'action':'SignUp.createAccount','registration_kind':'invited','csrf_token':app.csrf(signup),'user_first_name':'Regular','user_last_name':'Member','user_email':'http@example.test','user_name':'httpuser','user_password':'HTTP long password!','user_password_confirm':'HTTP long password!'}),200,'Regular member account can sign up')
app.login();csrf=app.token
app.fixture('verifyEmail','httpuser')
app.post('Roster.workspace',step='member',club_serial=club_a,identifier='httpuser',membership_status='active',joined_on='2026-01-01')
+20
View File
@@ -0,0 +1,20 @@
import os,subprocess,time
from application_harness import Application,PHP,ROOT
app=Application()
workers=[]
try:
ids=app.fixture('tenantSeed');env=app.env.copy();env['HCR_CLUB_EXPORT_HOURLY']='5';start=str(time.time()+2)
for n in range(12):
workers.append(subprocess.Popen(PHP+['tests/resource_worker.php',str(ids['admina' if n%2 else 'admina2']),str(ids['clubA']),start],cwd=ROOT,env=env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True))
outputs=[]
for worker in workers:
out,err=worker.communicate(timeout=20)
if worker.returncode:raise RuntimeError(err+out)
outputs.append(out)
assert outputs.count('allowed')==5 and outputs.count('limited')==7,outputs
print('PASS: Twelve simultaneous requests from two administrators share exactly five export slots')
print('1 resource concurrency scenario passed.')
finally:
for worker in workers:
if worker.poll() is None:worker.kill();worker.wait()
app.close()
+32
View File
@@ -0,0 +1,32 @@
import os
from application_harness import Application
for resource in ['EMAIL','TICKET','REPLY','EXPORT']:os.environ['HCR_CLUB_'+resource+'_HOURLY']='2'
app=Application();checks=0
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
def tickets():return app.fixture('inspect')['club_tickets']
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB']
app.login('membera@example.test','Tenant test password 123!')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='First',message='Body')[0]==200,'First member can submit within the shared ticket budget');tid=tickets()[0]['ticket_serial']
app.logout();app.login('shared@example.test','Tenant test password 123!')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='Second',message='Body')[0]==200,'Another member shares the same ticket budget')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='Limited',message='Body')[0]==429 and len(tickets())==2,'Club ticket limit rejects further work without partial records')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
for n in range(2):check(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=tid,message='Reply '+str(n))[0]==200,'Club reply budget permits an authorized reply')
check(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=tid,message='Limited')[0]==429,'Club reply limit is enforced through HTTP')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='one@example.test')[0]==200,'Invitation email uses the shared club budget')
check(app.post('RosterReports.csv',club_serial=a)[0]==200,'Roster export uses the shared export budget')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='two@example.test')[0]==200,'Another administrator shares the email budget')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='three@example.test')[0]==429,'Club email limit rejects additional invitations')
check(app.post('RosterReports.ledger',club_serial=a)[0]==200,'Different export formats share one club budget')
check(app.post('RosterReports.excel',club_serial=a)[0]==429,'Export budget applies across administrators and formats')
check(app.post('RosterReports.csv',club_serial=b)[0]==403,'An ordinary member cannot consume a foreign export budget')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(app.post('RosterReports.csv',club_serial=b)[0]==200,'Other club exports remain available after the first club reaches its limit')
check(app.post('Roster.workspace',step='invite',club_serial=b,invite_email='beta@example.test')[0]==200,'Other club email remains available after the first club reaches its limit')
print(checks,'resource HTTP checks passed.')
finally:app.close()
+9
View File
@@ -0,0 +1,9 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
$_SESSION=[Base::USER_SERIAL=>(int)$argv[1]];
$start=(float)$argv[3];if($start>microtime(true)+5)throw new RuntimeException('Invalid start time.');
while(microtime(true)<$start)usleep(1000);
try{(new ClubResources())->consume((int)$argv[2],'export');echo 'allowed';}
catch(AppException $e){if($e->status!==429)throw $e;echo 'limited';}
+1 -1
View File
@@ -3,5 +3,5 @@ from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.')
for suite in ['tests/source_checks.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/project_http.py','tests/production_runner.py','tests/concurrency.py','tests/browser_runner.py','tests/deployment_tools.py']:
for suite in ['tests/source_checks.py','tests/mfa_key_configuration.php','tests/mfa_qr_browser_runner.py','tests/mfa_lifecycle_http.py','tests/mfa_notifications_runner.py','tests/mfa_concurrency.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/club_onboarding_http.py','tests/member_summary_http.py','tests/member_actions_http.py','tests/project_http.py','tests/maintenance_http.py','tests/production_runner.py','tests/concurrency.py','tests/tenants_migration.py','tests/tenant_repairs_runner.py','tests/tenant_repair_concurrency.py','tests/ticket_repairs_http.py','tests/ticket_paging_http.py','tests/tenants_runner.py','tests/tenants_http.py','tests/browser_runner.py','tests/tenants_browser_runner.py','tests/ticket_repairs_browser_runner.py','tests/backup_runner.py','tests/deployment_tools.py','tests/security_capacity_http.py','tests/security_capacity_runner.py','tests/resource_http.py','tests/resource_concurrency.py']:
subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
+73
View File
@@ -0,0 +1,73 @@
"""Real HTTP MFA, recovery, privilege and session-expiration boundaries."""
import re
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
def sign_password(email='admin@example.test',password='Admin test password 123!'):
_,body,_=app.request()
return app.request(data={'action':'SignIn.login','csrf_token':app.csrf(body),'user_email':email,'user_password':password})
def security(body,code,password='Admin test password 123!',**extra):
return app.request(data={'action':'security','csrf_token':app.csrf(body),'mfa_code':code,'current_password':password,**extra})
def sid():return next(c.value for c in app.client.handlers[0].cookiejar if c.name=='HUNTINGCLUBROSTER')
def age(**values):
# Find the cookie processor without assuming handler ordering.
jar=next(h.cookiejar for h in app.client.handlers if hasattr(h,'cookiejar'))
cookie=next(c.value for c in jar if c.name=='HUNTINGCLUBROSTER')
app.fixture('sessionAge',cookie,str(values.get('absolute','')),str(values.get('idle','')),str(values.get('recent','')))
try:
ids=app.fixture('tenantSeed')
status,body,_=sign_password()
check(status==200 and b'setup key' in body,'Password alone exposes setup and no administrator workspace')
token=app.csrf(body)
check(app.request(data={'action':'Users.deleteUser','csrf_token':token,'user_serial':str(ids['membera'])},ajax=True)[0]==403,'AJAX cannot bypass mandatory enrollment')
check(app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(ids['clubA']))[1].find(b'setup key')>=0,'Direct club URLs cannot bypass enrollment')
check(app.request(data={'action':'security','mfa_code':'000000','current_password':'Admin test password 123!'})[0]==403,'MFA enrollment requires CSRF')
secret=re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode();code=app.totp(secret)
_,bad,_=security(body,code,'incorrect password')
check(b'Current password is incorrect' in bad and not app.fixture('mfaState','admin@example.test')['factor'],'Setup requires the current password and commits no partial factor')
status,body,_=security(bad,code)
codes=[c.decode() for c in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(codes)==10 and b'Save your recovery codes' in body,'Valid setup shows ten recovery codes once')
state=app.fixture('mfaState','admin@example.test')
check(secret not in state['factor']['secret_cipher'] and all(c not in state['recovery'] for c in codes),'Database stores encrypted secret and hashed recovery codes')
_,body,_=app.request();app.token=app.csrf(body)
check(b'Save your recovery codes' not in body,'Recovery code plaintext is absent on subsequent pages')
app.logout();_,body,_=sign_password()
check(b'setup key' not in body and b'Authentication code' in body,'Enrolled administrator must supply a second factor on a new login')
_,body,_=security(body,code)
check(b'Password or authentication code is incorrect' in body,'An authenticator code already used during setup cannot be replayed')
_,body,_=security(body,codes[0]);app.token=app.csrf(body)
check(b'Club workspace' in body and len(app.fixture('mfaState','admin@example.test')['recovery'])==9,'Recovery code completes login and is consumed')
app.logout();_,body,_=sign_password();_,body,_=security(body,codes[0])
check(b'Password or authentication code is incorrect' in body,'Consumed recovery code cannot be reused')
_,body,_=security(body,codes[1]);app.token=app.csrf(body)
age(recent=301)
check(app.post('Users.changePassword')[0]==403,'Administrator password changes also require recent second-factor confirmation')
check(app.post('Roster.workspace',step='member',club_serial=ids['clubA'],membership_serial=ids['clubA_shared'],membership_status='active',joined_on='2026-01-01',membership_role='administrator')[0]==403,'Privilege changes reject stale recent authentication')
_,body,_=app.request('/main.php?Xaction=security');_,body,_=security(body,codes[2]);app.token=app.csrf(body)
check(app.post('Roster.workspace',step='member',club_serial=ids['clubA'],membership_serial=ids['clubA_shared'],membership_status='active',joined_on='2026-01-01',membership_role='administrator')[0]==200,'Privilege change succeeds after fresh password and second factor')
age(absolute=28800,idle=0)
check(app.post('toggleTheme')[0]==401,'Active session still expires at the eight-hour absolute boundary')
_,body,_=sign_password();_,body,_=security(body,codes[3]);app.token=app.csrf(body);age(absolute=28799,idle=1801)
check(app.post('toggleTheme')[0]==401,'Idle timeout remains enforced independently')
_,body,_=sign_password();_,body,_=security(body,codes[4]);app.token=app.csrf(body)
_,body,_=app.request('/main.php?Xaction=security');_,body,_=security(body,codes[5],factor_action='replace')
check(b'setup key' in body and app.fixture('mfaState','admin@example.test')['factor'] and len(app.fixture('mfaState','admin@example.test')['recovery'])==4,'Pending replacement preserves the old factor and remaining recovery codes')
check(b'Club workspace' in app.request('/main.php?Xaction=Roster.workspace')[1],'Authorized session retains its existing MFA access during replacement')
newsecret=re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
check(newsecret!=secret,'Replacement generates a fresh authenticator secret')
status,limited,_=security(body,app.totp(newsecret),factor_action='confirmReplacement')
check(status==429 and b'Too many authentication attempts' in limited,'Repeated MFA attempts enforce a persistent account budget')
app.fixture('clearTestAttempts')
_,body,_=security(limited,app.totp(newsecret),factor_action='confirmReplacement');check(b'Save your recovery codes' in body,'Replacement authenticator can be enrolled with fresh recovery codes')
_,body,_=app.request();app.token=app.csrf(body);app.logout();app.login('membera@example.test','Tenant test password 123!')
check(b'Club workspace' in app.request()[1],'Ordinary unenrolled members retain password login')
app.logout();_,body,_=sign_password('shared@example.test','Tenant test password 123!')
check(b'setup key' in body,'Newly promoted administrator must enroll despite an ordinary global account role')
print(checks,'security capacity HTTP checks passed.')
except Exception:
app.log.seek(0);print(app.log.read().decode(errors='replace'));raise
finally:app.close()
+20
View File
@@ -0,0 +1,20 @@
import json,os,subprocess
from application_harness import Application,PHP,ROOT
os.environ['HCR_EXPORT_MAX_ROWS']='30'
app=Application()
try:
ids=app.fixture('tenantSeed');app.env['HCR_TENANT_IDS']=json.dumps(ids)
subprocess.run(PHP+['tests/capacity_service.php'],cwd=ROOT,env=app.env,check=True)
app.login('admina2@example.test','Tenant test password 123!');checks=0
for scope in ['members','seasons','dues','payments','adjustments','invitations']:
status,body,_=app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(ids['clubA'])+'&'+scope+'_page=9999')
assert status==200 and ('aria-label="'+scope+' pages"').encode() in body,(scope,status,body[:500])
checks+=1;print('PASS:',scope,'pager opens the final page through HTTP')
status,body,_=app.request('/main.php?Xaction=RosterReports.catalog&clubs_page=9999')
assert status==200 and b'Tenant Alpha' in body and b'Tenant Beta' not in body
checks+=1;print('PASS: Read-only report catalog paging retains managed-club scope')
for route in ['csv','excel','pdf','ledger']:
assert app.post('RosterReports.'+route,club_serial=ids['clubA'])[0]==422
checks+=1;print('PASS:',route,'export rejects the oversized selection through HTTP')
print(checks,'capacity page HTTP checks passed.')
finally:app.close()
+6
View File
@@ -18,3 +18,9 @@ assert base64.b64encode(hashlib.sha256((ROOT/dependency['file']).read_bytes()).d
assert not (ROOT/'js/jquery-3.2.0.min.js').exists()
assert all('jquery-3.2.0' not in file.read_text(encoding='utf-8-sig') for file in styles)
print(f'Source checks passed: {len(files)} PHP files, {len(scripts)} JavaScript files, {len(styles)} XSL stylesheets, and jQuery integrity.')
editor=json.loads((ROOT/'browser-dependencies.json').read_text())['suneditor']
for name,digest in editor['files'].items():assert hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest
assert not (ROOT/'js/ckeditor.js').exists()
assert all('ckeditor.js' not in file.read_text(encoding='utf-8-sig') for file in styles)
print('SunEditor asset integrity and obsolete editor removal passed.')
+26
View File
@@ -0,0 +1,26 @@
import json,subprocess
from application_harness import Application,PHP,ROOT
for operation in ['activate','deactivate']:
app=Application();workers=[]
try:
ids=app.fixture('tenantSeed');env=app.env.copy();env['HCR_TENANT_IDS']=json.dumps(ids)
record=app.fixture('repairConcurrencySeed',str(ids['clubA']),str(ids['invitee']),str(ids['admina']))
for mode,ident,ready in [(operation,record['membership'],'LOCKED'),('accept',record['invitation'],'WAITING')]:
p=subprocess.Popen(PHP+['tests/tenant_repair_worker.php',mode,str(ident)],cwd=ROOT,env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True);workers.append(p)
assert p.stdout.readline().strip()==ready,p.stderr.read()
workers[0].stdin.write('go\n');workers[0].stdin.flush();results=[]
for p in workers:
output,error=p.communicate(timeout=20);assert p.returncode==0,error+output;results.append(json.loads(output))
if operation=='activate':
assert all(x['ok'] for x in results),results
assert sum(x['user_serial']==ids['invitee'] and x['club_serial']==ids['clubA'] for x in app.fixture('inspect')['club_memberships'])==1
print('PASS: Invitation acceptance racing membership activation completes both requests without duplicate membership.')
else:
assert results[0]['ok'] and not results[1]['ok'] and results[1]['status']==404,results
member=next(x for x in app.fixture('inspect')['club_memberships'] if x['membership_serial']==record['membership']);assert member['membership_status']=='inactive'
print('PASS: Membership deactivation racing acceptance revokes the invitation and preserves denied access.')
finally:
for p in workers:
if p.poll() is None:p.terminate();p.wait(timeout=10)
app.close()
print('2 tenant repair concurrency scenarios passed.')
+24
View File
@@ -0,0 +1,24 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',(string)$settings->DatabaseName)||!in_array((string)$settings->DatabaseServer,['127.0.0.1','localhost'],true))throw new RuntimeException('Disposable local database required.');
$ids=json_decode(getenv('HCR_TENANT_IDS'),true,512,JSON_THROW_ON_ERROR);$operation=$argv[1];
class RepairLockStatement extends PDOStatement {
protected function __construct(private string $operation){}
public function execute(?array $params=null):bool{
if($this->operation==='accept'&&str_starts_with($this->queryString,'SELECT user_serial,user_email,user_email_verified_at,user_status')){echo "WAITING\n";flush();}
$ok=parent::execute($params);
if($this->operation!=='accept'&&str_starts_with($this->queryString,'SELECT user_serial,user_status,user_email_verified_at')){echo "LOCKED\n";flush();fgets(STDIN);}
return $ok;
}
}
$db=new PDO('mysql:host='.$settings->DatabaseServer.';port='.$settings->DatabasePort.';dbname='.$settings->DatabaseName.';charset=utf8mb4',(string)$settings->DatabaseUser,(string)$settings->DatabasePassword,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC,PDO::ATTR_EMULATE_PREPARES=>false,PDO::ATTR_STATEMENT_CLASS=>[RepairLockStatement::class,[$operation]]]);$db->exec('SET innodb_lock_wait_timeout=5');
class LockedRepairInvitations extends ClubInvitations {public function __construct(private PDO $db){}public function connect():PDO{return $this->db;}}
class LockedRepairMembers extends RosterService {public function __construct(private PDO $db){}public function connect():PDO{return $this->db;}}
$_SESSION=[Base::USER_SERIAL=>$operation==='accept'?$ids['invitee']:1,Base::USER_NAME=>'Concurrency tester'];
try{
if($operation==='accept')(new LockedRepairInvitations($db))->accept((int)$argv[2]);
else (new LockedRepairMembers($db))->saveMember($ids['clubA'],(int)$argv[2],'',$operation==='deactivate'?'inactive':'active','2026-01-01');
echo json_encode(['ok'=>true]),"\n";
}catch(AppException $e){echo json_encode(['ok'=>false,'status'=>$e->status]),"\n";}
+32
View File
@@ -0,0 +1,32 @@
<?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 repairCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo 'PASS: '.$message."\n";}
function repairActor(int $id):void{global $db;$s=$db->prepare('SELECT * FROM users WHERE user_serial=?');$s->execute([$id]);$u=$s->fetch();$_SESSION=[Base::USER_SERIAL=>$id,Base::USER_NAME=>$u['user_name'],Base::USER_ROLE=>$u['user_role']];}
class RepairInvitations extends ClubInvitations {protected function deliver(string $email,string $club):bool{return true;}}
$roster=new RosterService();$invites=new RepairInvitations();$access=new TenantAccess();
function invitationId(int $club,string $email):int{global $db;$s=$db->prepare('SELECT invitation_serial FROM club_invitations WHERE club_serial=? AND invite_email=?');$s->execute([$club,$email]);return (int)$s->fetchColumn();}
function ageInvitations():void{global $db;$db->exec('UPDATE club_invitations SET sent_at=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 2 MINUTE)');}
repairActor(1);$roster->saveMember($ids['clubA'],$ids['clubA_admina'],'','inactive','2026-01-01','administrator');
repairActor($ids['admina2']);$invites->invite($ids['clubA'],'admina@example.test','member');
repairActor($ids['admina']);$invites->accept(invitationId($ids['clubA'],'admina@example.test'));
repairCheck(!$access->canManage($ids['clubA']),'Member invitation cannot restore an inactive administrator role');
$row=$db->query('SELECT membership_role,membership_status FROM club_memberships WHERE membership_serial='.$ids['clubA_admina'])->fetch();
repairCheck($row['membership_role']==='member'&&$row['membership_status']==='active','Former administrator rejoins as the newly approved member role');
repairCheck(!$access->canManage($ids['clubB']),'Rejoining cannot change an ordinary membership in another club');
repairActor(1);ageInvitations();$invites->invite($ids['clubA'],'admina@example.test','administrator');
repairActor($ids['admina']);$invites->accept(invitationId($ids['clubA'],'admina@example.test'));
repairCheck($access->canManage($ids['clubA']),'Fresh Super System Administrator invitation can explicitly restore administrator access');
repairActor($ids['admina2']);$invites->invite($ids['clubA'],'admina2@example.test','member');$invites->accept(invitationId($ids['clubA'],'admina2@example.test'));
repairCheck($access->canManage($ids['clubA']),'Member invitation does not accidentally demote an already active administrator');
repairActor(1);$invites->invite($ids['clubA'],'membera@example.test','administrator');
$pending=invitationId($ids['clubA'],'membera@example.test');$roster->saveMember($ids['clubA'],$ids['clubA_membera'],'','active','2026-01-01','administrator');
repairCheck($db->query('SELECT invitation_status FROM club_invitations WHERE invitation_serial='.$pending)->fetchColumn()==='revoked','Explicit role changes revoke outstanding invitations for that membership');
ageInvitations();$invites->invite($ids['clubA'],'membera@example.test','administrator');$pending=invitationId($ids['clubA'],'membera@example.test');$roster->saveMember($ids['clubA'],$ids['clubA_membera'],'','active','2026-01-01','member');
repairActor($ids['membera']);try{$invites->accept($pending);throw new RuntimeException('Stale invitation was accepted');}catch(AppException $e){repairCheck($e->status===404,'A pending administrator invitation cannot undo a later explicit demotion');}
ageInvitations();repairActor($ids['admina2']);$invites->invite($ids['clubA'],'shared@example.test','member');repairActor(1);$roster->saveMember($ids['clubA'],$ids['clubA_shared'],'','inactive','2026-01-01');
repairActor($ids['shared']);try{$invites->accept(invitationId($ids['clubA'],'shared@example.test'));throw new RuntimeException('Deactivation invitation accepted');}catch(AppException $e){repairCheck($e->status===404,'Membership deactivation still invalidates outstanding invitations');}
repairCheck(count($roster->clubs())===1&&(int)$roster->clubs()[0]['club_serial']===$ids['clubB'],'Deactivation leaves access to the other club intact');
echo "$checks tenant repair service checks passed.\n";
+8
View File
@@ -0,0 +1,8 @@
import json,subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:
env=app.env.copy();env['HCR_TENANT_IDS']=json.dumps(app.fixture('tenantSeed'))
subprocess.run(PHP+['tests/tenant_repairs.php'],cwd=ROOT,env=env,check=True)
subprocess.run(PHP+['tests/transaction_retries.php'],cwd=ROOT,env=env,check=True)
finally:app.close()
+46
View File
@@ -0,0 +1,46 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const adminContext=await browser.newContext(),memberContext=await browser.newContext();
const admin=await adminContext.newPage(),member=await memberContext.newPage(),errors=[];let checks=0;
for(const page of [admin,member])page.on('pageerror',e=>errors.push(e.message));
const pass=message=>{checks++;console.log('PASS: '+message);};
const base=process.env.HCR_TEST_URL,ids=JSON.parse(process.env.HCR_TENANT_IDS);
async function login(page,email,password='Tenant test password 123!'){await page.goto(base+'/main.php');await page.locator('#user_email').fill(email);await page.locator('#user_password').fill(password);await Promise.all([page.waitForNavigation(),page.locator('#login button[type=submit]').click()]);await require('./browser_mfa')(page,password);}
try{
const ownerContext=await browser.newContext(),owner=await ownerContext.newPage();owner.on('pageerror',e=>errors.push(e.message));
await login(owner,'admin@example.test','Admin test password 123!');
const cards=owner.locator('.card').filter({has:owner.getByRole('link',{name:'Open club',exact:true})});assert.equal(await cards.count(),2);
const first=await cards.nth(0).boundingBox(),second=await cards.nth(1).boundingBox();assert(Math.abs(first.y-second.y)<2 && second.x>first.x);pass('Multiple accessible clubs appear side by side after login');
await owner.setViewportSize({width:390,height:844});const mobileFirst=await cards.nth(0).boundingBox(),mobileSecond=await cards.nth(1).boundingBox();assert(mobileSecond.y>=mobileFirst.y+mobileFirst.height && mobileSecond.x===mobileFirst.x);assert(await owner.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club cards stack without horizontal overflow on phones');await owner.setViewportSize({width:1280,height:720});
const singleContext=await browser.newContext(),single=await singleContext.newPage();await login(single,'membera@example.test');assert(new URL(single.url()).searchParams.get('club_serial')===String(ids.clubA));assert.equal(await single.getByRole('heading',{name:'Tenant Alpha',exact:true}).count(),1);assert.equal(await single.locator('.club-action-modal').count(),0);await singleContext.close();pass('A member with one club lands directly in its read-only workspace');
await owner.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA);
assert.equal(await owner.locator('.clubSummary h1').textContent(),'Tenant Alpha');assert.equal(await owner.locator('.clubSummary').getByRole('button',{name:'Create club',exact:true}).count(),0);assert(await owner.locator('.clubSummary header').getByRole('link',{name:'Switch club',exact:true}).count());pass('Club summary starts with club identity and actions, without a creation form');
await owner.getByRole('link',{name:'Create club',exact:true}).click();await owner.getByRole('heading',{name:'Create club',exact:true}).waitFor();pass('Super Administrator creates clubs from a separate page');await owner.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA);
await owner.locator('tr').filter({hasText:'shared@example.test'}).locator('[data-club-step=member]').click();const assignment=owner.locator('.club-action-modal.show form');await assignment.locator('select[name=membership_role]').selectOption('administrator');await Promise.all([owner.waitForNavigation(),assignment.getByRole('button',{name:'Save membership',exact:true}).click()]);
await owner.locator('tr').filter({hasText:'shared@example.test'}).locator('[data-club-step=member]').waitFor();assert((await owner.locator('tr').filter({hasText:'shared@example.test'}).textContent()).includes('administrator'));pass('Super System Administrator can appoint an administrator through the club membership form');
await ownerContext.close();
await login(admin,'admina2@example.test');assert(new URL(admin.url()).searchParams.get('club_serial')===String(ids.clubA));pass('An administrator with one club lands directly in the club after MFA');await admin.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA);
assert.equal(await admin.locator('.setup-users,.view-journal').count(),0);assert(!await admin.getByRole('link',{name:'Tenant Beta',exact:true}).count());pass('Club administrator browser navigation hides global controls and unrelated clubs');
if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT});
await admin.setViewportSize({width:390,height:844});if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-mobile.png')});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club summary actions and tables fit a phone-width screen');
await admin.locator('header [data-club-step=invite]').click();await admin.waitForFunction(()=>document.activeElement?.name==='invite_email');const mobileDialog=await admin.locator('.club-action-modal.show .modal-dialog').boundingBox();assert(mobileDialog.x>=0 && mobileDialog.x+mobileDialog.width<=390);await admin.keyboard.press('Escape');await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});await admin.waitForFunction(()=>document.activeElement?.dataset.clubStep==='invite');pass('Phone dialog fits the screen, focuses its first field and restores focus after Escape');await admin.setViewportSize({width:1280,height:720});
await admin.getByRole('link',{name:'Tenant Membera',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Membera',exact:true}).waitFor();assert((await admin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert.equal(await admin.locator('.memberSummary input:visible,.memberSummary select:visible').count(),0);pass('Roster name opens the selected member summary with corrected totals and hidden action forms');
await admin.locator('nav[aria-label="dues pages"]').getByRole('link',{name:'Next',exact:true}).click();assert((await admin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Member summary paging retains the selected member and complete totals');
if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});await admin.setViewportSize({width:390,height:844});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');await admin.setViewportSize({width:1280,height:720});
await admin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();await admin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((await admin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary');
await admin.locator('[data-club-step=charge]').click();await admin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});await admin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club');
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.getByText('Summary modal payment',{exact:false}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary');
await admin.getByRole('link',{name:'Back to club roster',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();
await admin.locator('header [data-club-step=invite]').click();const invite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});await invite.getByLabel('Invitation email').fill('invitee@example.test');await invite.getByRole('button',{name:'Send club invitation',exact:true}).click();await admin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly');
await login(member,'invitee@example.test');assert(await member.getByText('You are not assigned to an active club yet.',{exact:false}).count());pass('Accounts without a club retain the empty workspace and invitation access');await member.goto(base+'/main.php?Xaction=Roster.invitations');await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();await member.getByRole('button',{name:'Accept invitation',exact:true}).click();await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace');
await member.getByRole('link',{name:'Tenant Invitee',exact:true}).click();await member.getByRole('heading',{name:'Tenant Invitee',exact:true}).waitFor();await member.getByText('No dues recorded for this membership.',{exact:true}).waitFor();await member.getByText('No payments recorded for this membership.',{exact:true}).waitFor();pass('Member opens their own summary and sees clear empty financial histories');await member.getByRole('link',{name:'Back to club roster',exact:true}).click();
await member.getByRole('link',{name:'My tickets',exact:true}).click();const form=member.locator('form').filter({has:member.getByRole('button',{name:'Submit ticket',exact:true})});await form.getByLabel('Subject',{exact:true}).fill('Browser membership question');await form.getByLabel('Message',{exact:true}).fill('Please review my membership.');await form.getByRole('button',{name:'Submit ticket',exact:true}).click();await member.getByRole('heading',{name:'Browser membership question',exact:true}).waitFor();pass('Member submits a private club ticket through the browser');
await admin.goto(base+'/main.php?Xaction=Roster.tickets&club_serial='+ids.clubA);await admin.getByRole('link',{name:/Browser membership question/}).click();const reply=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send reply',exact:true})});await reply.getByLabel('Reply',{exact:true}).fill('Your membership is active.');await reply.getByLabel('Status',{exact:true}).selectOption('closed');await reply.getByRole('button',{name:'Send reply',exact:true}).click();await admin.getByText('Status: closed',{exact:true}).waitFor();pass('Assigned club administrator replies to and closes the member ticket');
await member.reload();await member.getByText('Your membership is active.',{exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send reply',exact:true}).count(),0);pass('Member sees the response and cannot change a closed ticket');
const signupContext=await browser.newContext(),signup=await signupContext.newPage();signup.on('pageerror',e=>errors.push(e.message));await signup.goto(base+'/main.php?Xaction=signup');await signup.getByLabel('New club name',{exact:true}).fill('Browser Signup Club');await signup.getByLabel('First name',{exact:true}).fill('Browser');await signup.getByLabel('Last name',{exact:true}).fill('Owner');await signup.getByLabel('Email address',{exact:true}).fill('browserowner@example.test');await signup.getByLabel('Username',{exact:true}).fill('browserowner');await signup.getByLabel('Password',{exact:true}).fill('Browser owner password 123!');await signup.getByLabel('Confirm password',{exact:true}).fill('Browser owner password 123!');await signup.getByRole('button',{name:'Create account',exact:true}).click();await signup.getByText('Your account has been created.',{exact:false}).waitFor();pass('Browser signup registers a new club and its administrator');await signup.goto(base+'/main.php?Xaction=signup&membership=invited');assert.equal(await signup.getByLabel('New club name',{exact:true}).count(),0);await signup.getByText('Use the email address your club administrator invited.',{exact:false}).waitFor();pass('Invited members have a separate account signup without a club creation field');await signupContext.close();
assert.deepEqual(errors,[]);pass('Tenant invitation and ticket flows produce no browser script errors');
}catch(error){console.error('OPEN DIALOG:',await admin.locator('.club-action-modal.show').textContent().catch(()=>''));if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-failure.png')});throw error;}finally{await browser.close();}
console.log(checks+' tenant browser checks passed.');
})().catch(e=>{console.error(e);process.exitCode=1;});
+8
View File
@@ -0,0 +1,8 @@
import json,os,subprocess
from application_harness import Application,ROOT
app=Application()
try:
env=app.env.copy();env['HCR_TEST_URL']=app.base;ids=app.fixture('tenantSeed');env['HCR_TENANT_IDS']=json.dumps(ids)
app.fixture('memberSummarySeed',str(ids['clubA']),str(ids['clubB']),str(ids['clubA_membera']),str(ids['clubA_shared']),str(ids['clubB_shared']))
subprocess.run([env.get('NODE_TEST_COMMAND','node'),'tests/tenants_browser.js'],cwd=ROOT,env=env,check=True)
finally:app.close()
+64
View File
@@ -0,0 +1,64 @@
import json,secrets,subprocess
from application_harness import Application,PHP,ROOT
app=Application();checks=[]
def check(ok,message):
if not ok:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def expect(response,status,message):
check(response[0]==status,message+' (HTTP '+str(response[0])+')');return response[1]
def login(who):app.logout();app.login(who+'@example.test','Tenant test password 123!')
def workspace(club):return app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(club))
try:
ids=app.fixture('tenantSeed');a,b=ids['clubA'],ids['clubB'];app.login()
before=app.fixture('fingerprints');subprocess.run(PHP+['bin/migrate-tenants.php'],cwd=ROOT,env=app.env,check=True,capture_output=True);check(app.fixture('fingerprints')==before,'Tenant migration is idempotent without changing existing rows or promoting accounts')
for club,label,member in [(a,'Alpha',ids['clubA_membera']),(b,'Beta',ids['clubB_memberb'])]:
expect(app.post('Roster.workspace',step='season',club_serial=club,season_name=label+' Season',starts_on='2026-01-01',ends_on='2026-12-31'),200,'Super administrator adds '+label+' season')
season=app.fixture('inspect')['club_seasons'][-1]['season_serial']
expect(app.post('Roster.workspace',step='charge',club_serial=club,membership_serial=member,season_serial=season,amount='100.00'),200,'Super administrator records '+label+' dues')
data=app.fixture('inspect');dueA=next(x for x in data['club_dues'] if x['club_serial']==a)['due_serial'];dueB=next(x for x in data['club_dues'] if x['club_serial']==b)['due_serial'];seasonB=next(x for x in data['club_seasons'] if x['club_serial']==b)['season_serial']
login('admina2')
body=expect(workspace(a),200,'Assigned club administrator can open the club with global User role');check(b'membera@example.test' in body and b'shared@example.test' in body and b'memberb@example.test' not in body,'Club administrator sees all shared-club members and no unrelated tenant members')
home=app.request('/main.php?Xaction=Roster.workspace')[1];check(b'Tenant Beta' not in home and b'setup-users' not in home and b'view-journal' not in home,'Club administrator navigation hides other tenants and global administration')
for action,fields in [
('Users.setupUsers',{}),('Users.userSummary',{'user_serial':ids['memberb']}),('Users.editUser',{'step':'prompt','user_serial':ids['membera']}),('Users.editUser',{'step':'update','user_serial':ids['memberb'],'user_role':'Super System Administrator'}),
('Users.addUser',{'step':'add','user_email':'probe@example.test'}),('Users.deleteUser',{'user_serial':ids['memberb']}),('Users.resetPassword',{'user_serial':ids['memberb']}),('Users.updateActiveStatus',{'user_serial':ids['memberb'],'user_active':'false'}),('Users.userExists',{'user_name':'tenantmemberb'}),('Users.getUser_AJAX',{'user_name':'tenantmemberb'}),('Users.userActive',{'user_serial':ids['memberb']}),
('Notes.addNote',{'step':'add','note_type':'User','note_reference':ids['memberb'],'note_text':'Foreign note'}),('Journal.viewJournal',{}),('Alerts.setupAlerts',{}),('Popovers.setupPopovers',{}),('Statuses.setupStatuses',{}),('Dropdowns.setupDropdowntypes',{})]:
expect(app.post(action,**fields),403,'Club administrator cannot use global route '+action)
expect(workspace(b),403,'Changing the URL cannot open an unrelated tenant')
before=app.fixture('fingerprints')
for step,fields in [('club',{'club_name':'Stolen tenant'}),('member',{'membership_serial':ids['clubB_memberb'],'membership_status':'inactive','joined_on':'2026-01-01'}),('season',{'season_name':'Foreign season','starts_on':'2026-01-01','ends_on':'2026-12-31'}),('charge',{'membership_serial':ids['clubB_memberb'],'season_serial':seasonB,'amount':'10.00'}),('payment',{'due_serial':dueB,'amount':'10.00','paid_on':'2026-01-01','request_key':secrets.token_hex(32)}),('adjust',{'due_serial':dueB,'corrected_amount':'90.00','reason':'Foreign','request_key':secrets.token_hex(32)}),('invite',{'invite_email':'foreign@example.test'})]:
expect(app.post('Roster.workspace',step=step,club_serial=b,**fields),403,'Foreign tenant rejects '+step+' mutation')
check(app.fixture('fingerprints')==before,'Rejected cross-tenant writes leave all stored tables unchanged')
for export in ['csv','excel','pdf','ledger']:expect(app.post('RosterReports.'+export,club_serial=b),403,'Foreign tenant denies '+export+' export')
report=expect(app.post('Reports.selectReport'),200,'Club administrator can open scoped reports');check(b'Tenant Alpha' in report and b'Tenant Beta' not in report,'Report catalog includes only managed clubs')
csv=expect(app.post('RosterReports.csv',club_serial=a),200,'Club administrator can export the assigned club');check(b'membera@example.test' in csv and b'memberb@example.test' not in csv,'Roster export contains only the selected tenant')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueB,amount='10.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),404,'A foreign dues ID cannot be paired with an allowed club')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueA,amount='10.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),200,'Club administrator can record payment for an assigned-club member')
expect(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='invitee@example.test',membership_role='administrator'),403,'Club administrator cannot forge an administrator invitation')
users_before=app.fixture('fingerprints')['users'];invite_body=expect(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='invitee@example.test'),200,'Club administrator can invite a member by email');check(app.fixture('fingerprints')['users']==users_before,'Inviting an existing account does not change its login, email, role or password');check(b'could not be sent' in invite_body,'Invitation delivery failure remains visible in development')
invitation=app.fixture('inspect')['club_invitations'][0]['invitation_serial']
expect(app.post('Roster.workspace',step='member',club_serial=a,membership_serial=ids['clubA_shared'],membership_status='inactive',joined_on='2026-01-01'),200,'Club administrator can deactivate a membership in their club')
changed=app.fixture('inspect');check(next(x for x in changed['club_memberships'] if x['membership_serial']==ids['clubB_shared'])['membership_status']=='active' and next(x for x in changed['users'] if x['user_serial']==ids['shared'])['user_status']=='active','Membership deactivation leaves the shared account and other club membership active')
expect(app.post('Roster.workspace',step='member',club_serial=a,membership_serial=ids['clubA_shared'],membership_status='active',joined_on='2026-01-01'),200,'Club administrator can reactivate their own club membership')
login('admina');check(b'invitee@example.test' in workspace(a)[1],'Another administrator of the shared club sees its invitation')
own=expect(workspace(b),200,'Administrator of Alpha can read Beta as an ordinary Beta member');check(b'memberb@example.test' not in own and b'Record received payment' not in own,'Administrator in one club has member-only visibility in another club')
expect(app.post('RosterReports.csv',club_serial=b),403,'Being an ordinary member does not permit exporting that club')
login('membera');expect(app.post('Roster.invitations',step='accept',invitation_serial=invitation),404,'Another signed-in member cannot steal an invitation')
login('invitee');pending=expect(app.request('/main.php?Xaction=Roster.invitations'),200,'Invited member can open pending invitations');check(b'Tenant Alpha' in pending,'Correct invited email sees the club offer');expect(app.post('Roster.invitations',step='accept',invitation_serial=invitation),200,'Verified invited member accepts through the HTTP handler')
login('membera');member=expect(workspace(a),200,'Member can read assigned club and own dues');check(b'My season dues' in member and b'$90.00' in member and b'admina2@example.test' not in member and b'shared@example.test' not in member,'Member sees personal membership/dues rather than other member records')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueA,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),403,'Members cannot mutate financial records')
expect(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='My dues question',message='<script>alert(1)</script> Please explain my dues.'),200,'Member can submit a club ticket')
ticket=app.fixture('inspect')['club_tickets'][0]['ticket_serial'];page=expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Member can read their submitted ticket');check(b'&lt;script&gt;' in page and b'<script>alert(1)</script>' not in page,'Ticket content is escaped when displayed')
missing=app.request(data={'action':'Roster.tickets','step':'reply','club_serial':a,'ticket_serial':ticket,'message':'No token'},ajax=True);expect(missing,403,'Ticket replies require CSRF protection')
login('shared');expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),404,'Another member in the same club cannot read a private ticket')
login('adminb');expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),403,'Other tenant administrator cannot read a ticket')
login('admina2');expect(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=ticket,message='Your payment was received.',ticket_status='closed'),200,'Assigned club administrator can answer and close the ticket')
login('membera');page=expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Member can read the administrator response');check(b'Your payment was received.' in page and b'Status: closed' in page,'Ticket reply and closed status are visible to its author')
app.logout();app.login();all_data=expect(workspace(b),200,'Super System Administrator can open any tenant');check(b'memberb@example.test' in all_data,'Super System Administrator can see all associated member data')
expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Super System Administrator can inspect tickets across tenants')
# Removing a club assignment takes effect in existing sessions on the next request.
login('admina2');app.fixture('demoteTenantAdmin',str(ids['clubA_admina2']))
expect(app.post('Roster.workspace',step='season',club_serial=a,season_name='Revoked privilege',starts_on='2026-01-01',ends_on='2026-12-31'),403,'Club administrator demotion immediately removes management access without a new login')
finally:app.close()
print(str(len(checks))+' tenant HTTP checks passed.')
+20
View File
@@ -0,0 +1,20 @@
import subprocess
from application_harness import Application,PHP,ROOT
app=Application();checks=[]
def check(ok,message):
if not ok:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
try:
ids=app.fixture('tenantSeed');app.fixture('legacyTenantSchema')
before=app.fixture('inspect');env=app.env.copy();env['HCR_SUPER_ADMIN_EMAIL']='admina@example.test'
result=subprocess.run(PHP+['bin/migrate-tenants.php'],cwd=ROOT,env=env,capture_output=True,text=True);check(result.returncode==0,'Tenant migration upgrades the legacy membership schema')
after=app.fixture('inspect');selected=next(x for x in after['users'] if x['user_serial']==ids['admina']);old=next(x for x in before['users'] if x['user_serial']==ids['admina'])
check(selected['user_role']=='Super System Administrator' and selected['user_auth_version']==old['user_auth_version']+1,'Only the explicitly designated account is promoted and its sessions are revoked')
check(next(x for x in after['users'] if x['user_serial']==ids['adminb'])['user_role']=='Administrator','Another legacy Administrator is not promoted')
check(all(x['membership_role']=='member' for x in after['club_memberships']),'Migration never guesses club administrator assignments for legacy memberships')
check([{k:v for k,v in x.items() if k!='membership_role'} for x in after['club_memberships']]==before['club_memberships'] and after['clubs']==before['clubs'],'Club data and existing memberships are preserved')
fingerprints=app.fixture('fingerprints');subprocess.run(PHP+['bin/migrate-tenants.php'],cwd=ROOT,env=env,capture_output=True,check=True);check(app.fixture('fingerprints')==fingerprints,'Rerunning explicit promotion leaves rows, sessions and audit unchanged')
env['HCR_SUPER_ADMIN_EMAIL']='missing-owner@example.test';result=subprocess.run(PHP+['bin/migrate-tenants.php'],cwd=ROOT,env=env,capture_output=True);check(result.returncode!=0 and app.fixture('fingerprints')==fingerprints,'Missing designated account fails without promoting another account')
app.login();response=app.post('Roster.workspace',step='club',club_serial=0,club_name='Tenant Alpha');check(response[0]==200 and len(app.fixture('inspect')['clubs'])==3,'Independent clubs may use the same display name after migration')
finally:app.close()
print(str(len(checks))+' tenant migration checks passed.')
+7
View File
@@ -0,0 +1,7 @@
import json,os,subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:
ids=app.fixture('tenantSeed');env=app.env.copy();env['HCR_TENANT_IDS']=json.dumps(ids)
subprocess.run(PHP+['tests/tenants_service.php'],cwd=ROOT,env=env,check=True)
finally:app.close()
+49
View File
@@ -0,0 +1,49 @@
<?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";
+43
View File
@@ -0,0 +1,43 @@
import re,urllib.parse
from application_harness import Application
app=Application();checks=[]
def check(ok,message):
if not ok:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def get(club,**fields):return app.request('/main.php?'+urllib.parse.urlencode({'Xaction':'Roster.tickets','club_serial':club,**fields}))
def expect(response,status,message):
check(response[0]==status,message+' (HTTP '+str(response[0])+')');return response[1]
def listed(body):
part=re.search(rb'<ul class="ticket-list">(.*?)</ul>',body,re.S);return re.findall(rb'ticket_serial=([0-9]+)',part[1])
try:
ids=app.fixture('tenantSeed');a,b=ids['clubA'],ids['clubB'];seed=app.fixture('ticketPagingSeed',str(a),str(b),str(ids['membera']),str(ids['shared']),str(ids['memberb']))
app.login('membera@example.test','Tenant test password 123!')
body=expect(get(a),200,'Member opens first ticket page');check(len(listed(body))==25 and b'Tickets 1-25 of 102. Page 1 of 5.' in body,'First page is bounded and count includes only personal tickets')
seen=[]
for page in range(1,6):
body=expect(get(a,ticket_page=page),200,'Ticket page '+str(page)+' loads');seen+=listed(body)
check(b'Private neighbor ticket' not in body and b'Archive foreign club ticket' not in body,'Page '+str(page)+' excludes private and foreign tickets')
check(len(seen)==102 and len(set(seen))==102,'Paging returns every personal ticket once in stable serial order')
check(str(seed['oldest']).encode() in seen,'Ticket older than the previous 100-ticket ceiling is discoverable')
body=expect(get(a,ticket_page=9999),200,'Excessive page number is safely clamped');check(b'Page 5 of 5.' in body and len(listed(body))==2,'Last page shows the correct final two records')
for page in ['0','-1','1 OR 1=1','1.5']:
expect(get(a,ticket_page=page),422,'Malformed ticket page is rejected: '+page)
for literal in ['%','_','!']:
body=expect(get(a,ticket_search=literal),200,'Literal wildcard search works: '+literal);check(listed(body)==[str(seed['literal']).encode()],'Search treats '+literal+' as literal text rather than a wildcard')
body=expect(get(a,ticket_search='Archive ticket 001'),200,'Search finds an old ticket');check(listed(body)==[str(seed['oldest']).encode()] and b'Page 1 of 1.' in body,'Search count and rows use the same authorization filter')
body=expect(get(a,ticket_search='%'),200,'Stored HTML subject is safely searchable');check(b'&lt;b&gt;needle&lt;/b&gt;' in body and b'<b>needle</b>' not in body,'Ticket subject markup is escaped in search results')
body=expect(get(a,ticket_search='"><script>alert(1)</script>'),200,'Search handles HTML-shaped text');check(b'<script>alert(1)</script>' not in body,'Search text cannot inject HTML into the form')
expect(get(a,ticket_search='x'*101),422,'Overlong search gets a validation error')
expect(get(b,ticket_page=2,ticket_search='Archive'),403,'Changing club ID during paging or search is denied')
expect(get(a,ticket_serial=seed['private'],ticket_page=5),404,'Paging cannot open another member private ticket by ID')
# A reply retains its old-ticket list context, while refreshing is a read-only GET.
response=app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=seed['oldest'],ticket_page=5,ticket_search='Archive',message='Reply to older ticket')
body=expect(response,200,'Replying to an older ticket preserves navigation');check(b'Page 5 of 5.' in body and b'value="Archive"' in body and b'Reply to older ticket' in body,'Reply redirect preserves search and page context')
app.logout();app.login('shared@example.test','Tenant test password 123!')
body=expect(get(a,ticket_page=5),200,'Other member has a separately scoped ticket list');check(b'of 1. Page 1 of 1.' in body and b'Archive ticket' not in body,'Other member counts do not expose personal ticket totals')
body=expect(get(a,ticket_search='Archive'),200,'Other member search stays private');check(b'Tickets 0-0 of 0.' in body,'Search counts do not reveal other member matches')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
body=expect(get(a,ticket_page=5),200,'Club administrator can page through all club tickets');check(b'of 103. Page 5 of 5.' in body and str(seed['oldest']).encode() in listed(body),'Administrator can find older tickets from every club member')
expect(get(b,ticket_search='Archive'),403,'Administrator cannot search another tenant')
finally:app.close()
print(str(len(checks))+' ticket paging HTTP checks passed.')
+24
View File
@@ -0,0 +1,24 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const page=await browser.newPage(),errors=[];let checks=0;
page.on('pageerror',e=>errors.push(e.message));
const pass=message=>{checks++;console.log('PASS: '+message);};
const base=process.env.HCR_TEST_URL,ids=JSON.parse(process.env.HCR_TENANT_IDS);
const click=async locator=>Promise.all([page.waitForNavigation({waitUntil:'load'}),locator.click()]);
try{
await page.goto(base+'/main.php');await page.locator('#user_email').fill('membera@example.test');await page.locator('#user_password').fill('Tenant test password 123!');await click(page.locator('#login button[type=submit]'));
await page.goto(base+'/main.php?Xaction=Roster.tickets&club_serial='+ids.clubA);
assert.equal(await page.locator('.ticket-list li').count(),25);assert((await page.locator('.ticket-page-status').innerText()).includes('of 102.'));pass('Browser shows a bounded personal ticket page with its own total');
await click(page.getByRole('link',{name:'Last page',exact:true}));assert.equal(await page.locator('.ticket-list li').count(),2);await page.getByRole('link',{name:/Archive ticket 001$/}).waitFor();pass('Last-page navigation exposes tickets older than the former 100-ticket limit');
const search=page.locator('form').filter({has:page.getByRole('button',{name:'Search tickets',exact:true})});await search.getByLabel('Search ticket subjects',{exact:true}).fill('Archive ticket 001');await click(search.getByRole('button',{name:'Search tickets',exact:true}));assert.equal(await page.locator('.ticket-list li').count(),1);assert((await page.locator('.ticket-page-status').innerText()).includes('Page 1 of 1.'));pass('Browser search resets paging and locates an older authorized ticket');
await click(page.getByRole('link',{name:/Archive ticket 001$/}));await page.getByRole('heading',{name:'Archive ticket 001',exact:true}).waitFor();pass('An old ticket opens from its filtered list');
const form=page.locator('form').filter({has:page.getByRole('button',{name:'Submit ticket',exact:true})}),body=form.getByLabel('Message',{exact:true}),subject=form.getByLabel('Subject',{exact:true});
await subject.fill('😀'.repeat(200));assert(await subject.evaluate(e=>e.checkValidity()));await subject.fill('x'.repeat(201));assert(!await subject.evaluate(e=>e.checkValidity()));pass('Subject validation counts Unicode characters rather than UTF-16 units');
await subject.fill('Unicode browser ticket');await body.fill('界'.repeat(8001));assert(!await body.evaluate(e=>e.checkValidity()));assert((await body.evaluate(e=>e.validationMessage)).includes('8000'));pass('Browser rejects an overlong Unicode message with a specific limit');
await body.fill('😀'.repeat(8000));assert.equal(await body.getAttribute('maxlength'),'16000');assert(await body.evaluate(e=>e.checkValidity()));await click(form.getByRole('button',{name:'Submit ticket',exact:true}));await page.getByRole('heading',{name:'Unicode browser ticket',exact:true}).waitFor();assert((await page.locator('article.card').first().innerText()).includes('😀'.repeat(8000)));pass('An 8,000-character emoji message is accepted and preserved through the browser');
assert.deepEqual(errors,[]);pass('Ticket paging, search and Unicode controls produce no browser script errors');
}finally{await browser.close();}
console.log(checks+' ticket repair browser checks passed.');
})().catch(e=>{console.error(e);process.exitCode=1;});
+8
View File
@@ -0,0 +1,8 @@
import json,subprocess
from application_harness import Application,ROOT
app=Application()
try:
ids=app.fixture('tenantSeed');app.fixture('ticketPagingSeed',str(ids['clubA']),str(ids['clubB']),str(ids['membera']),str(ids['shared']),str(ids['memberb']))
env=app.env.copy();env['HCR_TEST_URL']=app.base;env['HCR_TENANT_IDS']=json.dumps(ids)
subprocess.run([env.get('NODE_TEST_COMMAND','node'),'tests/ticket_repairs_browser.js'],cwd=ROOT,env=env,check=True)
finally:app.close()
+29
View File
@@ -0,0 +1,29 @@
from application_harness import Application
app=Application();checks=[]
def check(ok,message):
if not ok:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def expect(response,status,message):
check(response[0]==status,message+' (HTTP '+str(response[0])+')');return response[1]
try:
ids=app.fixture('tenantSeed');club=ids['clubA'];app.login('membera@example.test','Tenant test password 123!')
for text,label in [('界'*3000,'Three-thousand-character Unicode message'),('😀'*8000,'Eight-thousand-character four-byte message')]:
expect(app.post('Roster.tickets',step='openTicket',club_serial=club,subject=label,message=text),200,label+' can be submitted')
ticket=app.fixture('inspect')['club_tickets'][-1]['ticket_serial'];body=app.request(f'/main.php?Xaction=Roster.tickets&club_serial={club}&ticket_serial={ticket}')[1]
check(text.encode() in body,label+' is preserved without truncation')
ticket=app.fixture('inspect')['club_tickets'][-1]['ticket_serial']
expect(app.post('Roster.tickets',step='reply',club_serial=club,ticket_serial=ticket,message='😀'*8000),200,'Unicode reply accepts the same character boundary')
for text,label in [('x'*8001,'Oversized ASCII message'),('😀'*8001,'Oversized Unicode message')]:
before=app.fixture('fingerprints');body=expect(app.post('Roster.tickets',step='openTicket',club_serial=club,subject='Too long',message=text),422,label+' gets a field validation error')
check(b'Message must be 8000 characters or fewer.' in body,label+' explains the supported limit')
after=app.fixture('fingerprints');check(after['club_tickets']==before['club_tickets'] and after['club_ticket_messages']==before['club_ticket_messages'],label+' creates no ticket or partial message')
before=app.fixture('fingerprints');expect(app.post('Roster.tickets',step='reply',club_serial=club,ticket_serial=ticket,message='😀'*8001),422,'Oversized Unicode reply is rejected')
check(app.fixture('fingerprints')['club_ticket_messages']==before['club_ticket_messages'],'Rejected reply leaves message history unchanged')
expect(app.post('Roster.tickets',step='openTicket',club_serial=club,subject='Hard limit',message='x'*65537),400,'Hard transport ceiling still rejects extreme ticket bodies')
expect(app.post('Roster.tickets',step='openTicket',club_serial=club,subject='Array',**{'message[]':'bad'}),400,'Array-shaped ticket body is rejected')
expect(app.post('Roster.tickets',step='openTicket',club_serial=club,subject='x'*8193,message='Normal'),400,'Larger message allowance does not widen other field byte limits')
expect(app.post('Roster.workspace',step='club',club_serial=club,club_name='x'*9000),400,'Larger message allowance does not widen other routes')
response=app.request('/main.php?Xaction=Roster.tickets&user_email%5B%5D=bad',data={'action':'Roster.tickets','csrf_token':app.token,'user_email':'valid@example.test'},ajax=True)
expect(response,400,'A POST field cannot hide an array-shaped query field')
finally:app.close()
print(str(len(checks))+' ticket repair HTTP checks passed.')
+24
View File
@@ -0,0 +1,24 @@
<?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 retryCheck(bool $ok,string $label):void{global $checks;if(!$ok)throw new RuntimeException($label);$checks++;echo 'PASS: '.$label."\n";}
$insert=static function(PDO $db,string $subject)use($ids):void{$s=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$s->execute([$ids['clubA'],$ids['membera'],$subject]);};
$attempts=0;$value=DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Retried transaction');if($attempts<3)$db->exec("SIGNAL SQLSTATE '40001' SET MYSQL_ERRNO=1213,MESSAGE_TEXT='Controlled deadlock'");return 'committed';});
retryCheck($attempts===3&&$value==='committed','Deadlock retries return the successful whole-transaction result');
retryCheck((int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Retried transaction'")->fetchColumn()===1,'Retry rollbacks prevent duplicate ticket writes');
$attempts=0;try{DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Exhausted transaction');$db->exec("SIGNAL SQLSTATE 'HY000' SET MYSQL_ERRNO=1205,MESSAGE_TEXT='Controlled timeout'");});throw new RuntimeException('Expected timeout');}catch(PDOException $e){retryCheck($attempts===3&&(int)$e->errorInfo[1]===1205,'Lock timeout retries stop after three attempts');}
retryCheck(!$db->inTransaction()&&!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Exhausted transaction'")->fetchColumn(),'Exhausted retries leave no open transaction or partial write');
$attempts=0;try{DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Rejected transaction');throw new AppException('Denied',403);});}catch(AppException $e){retryCheck($attempts===1&&$e->status===403,'Business and permission errors are never retried');}
retryCheck(!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Rejected transaction'")->fetchColumn(),'Permission failure rolls back its writes');
// Simulate a grant changing after the first attempt rolls back, before retry.
class RevocationPDO extends PDO {
public $afterRollback;
public function rollBack():bool{$ok=parent::rollBack();if($this->afterRollback){$call=$this->afterRollback;$this->afterRollback=null;$call();}return $ok;}
}
$settings=(new Base())->getSettings();$retryDb=new RevocationPDO('mysql:host='.$settings->DatabaseServer.';port='.$settings->DatabasePort.';dbname='.$settings->DatabaseName.';charset=utf8mb4',(string)$settings->DatabaseUser,(string)$settings->DatabasePassword,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);
$_SESSION=[Base::USER_SERIAL=>$ids['admina2']];$retryDb->afterRollback=function()use($db,$ids){$db->exec("UPDATE club_memberships SET membership_role='member' WHERE membership_serial=".$ids['clubA_admina2']);};$attempts=0;
try{DatabaseTransaction::run($retryDb,function(PDO $db)use(&$attempts,$ids,$insert){$attempts++;(new TenantAccess())->requireManager($ids['clubA'],$db);$insert($db,'Revoked transaction');$db->exec("SIGNAL SQLSTATE '40001' SET MYSQL_ERRNO=1213,MESSAGE_TEXT='Controlled deadlock'");});throw new RuntimeException('Revoked write was allowed');}catch(AppException $e){retryCheck($attempts===2&&$e->status===403,'Authorization is checked again after a concurrent revocation');}
retryCheck(!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Revoked transaction'")->fetchColumn(),'Revoked authority cannot commit a retried write');
echo "$checks transaction retry checks passed.\n";