generated from jric11/baseProject
ChatGPT's update to harden software and run db updates.
This commit is contained in:
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.')
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
@@ -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'<script>unsafe</script>' 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()
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
@@ -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);});
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.')
|
||||
@@ -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";}
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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'<script>' 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.')
|
||||
@@ -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.')
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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'<b>needle</b>' 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.')
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user