Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled

This commit is contained in:
2026-10-20 06:45:30 -04:00
parent 02223083f6
commit 1e63076123
116 changed files with 3252 additions and 2932 deletions
+62
View File
@@ -0,0 +1,62 @@
"""Disposable HTTP test application. Requires an explicitly supplied test database server."""
import http.cookiejar
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
ROOT=Path(__file__).resolve().parents[1]
PHP=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
class Application:
def __init__(self):
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.database=self.fixture('create')['database']
self.env['HCR_DB_NAME']=self.database
with socket.socket() as available:
available.bind(('127.0.0.1',0));port=available.getsockname()[1]
self.base=f'http://127.0.0.1:{port}'
self.log=tempfile.TemporaryFile(mode='w+b')
self.server=subprocess.Popen(PHP+['-S',f'127.0.0.1:{port}','bin/dev-router.php'],cwd=ROOT,env=self.env,stdout=self.log,stderr=self.log)
self.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
for _ in range(100):
try:self.request();break
except urllib.error.URLError:time.sleep(.1)
else:self.close();raise RuntimeError('Test HTTP server did not start.')
def fixture(self,action,*extra):
args=PHP+['tests/http_fixture.php',action]+([self.database] if hasattr(self,'database') else [])+list(extra)
result=subprocess.run(args,cwd=ROOT,env=self.env,capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
return json.loads(result.stdout.splitlines()[-1]) if result.stdout.strip() else {}
def request(self,path='/',data=None,ajax=False):
req=urllib.request.Request(self.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 response:return response.status,response.read(),response.headers
except urllib.error.HTTPError as error:return error.code,error.read(),error.headers
@staticmethod
def csrf(body):
match=re.search(rb'name="csrf(?:_token|-token)"[^>]*(?:value|content)="([a-f0-9]+)"',body)
if not match:raise AssertionError('Missing CSRF token: '+body[:500].decode(errors='replace'))
return match.group(1).decode()
def login(self,email='admin@example.test',password='Admin test password 123!'):
_,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'))
self.token=self.csrf(body)
def post(self,action,**values):
return self.request(data={'action':action,'csrf_token':self.token,**values},ajax=True)
def logout(self):self.post('signout')
def close(self):
self.server.terminate();self.server.wait(timeout=10)
self.fixture('drop')
self.log.seek(0);folder=ROOT/'tests/.local';folder.mkdir(exist_ok=True);(folder/'last-http.log').write_bytes(self.log.read());self.log.close()
+1 -1
View File
@@ -90,7 +90,7 @@ try:
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': 'print', 'report': 'Email', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
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')
status, html, _ = request(data={'action': 'signout', 'csrf_token': token})
+1
View File
@@ -51,6 +51,7 @@ try {
foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
if (trim($sql)) $server->exec($sql);
}
require __DIR__.'/../tables/journal.php';$server->exec($SQL);
require __DIR__ . '/../views/view_users.php';
$server->exec($SQL);
$auth = new TestAuth($server);
+54
View File
@@ -0,0 +1,54 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const path=require('node:path');
const fs=require('node:fs/promises');
(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:1440,height:1000}});
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 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');
pass('Browser login loads the application shell without script errors');
const safe=await page.evaluate(()=>{
const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){};
try{const value='"><img id="injected-hidden-value" src=x>';formSubmit({action:'Users.setupUsers',find_user:value});return !document.querySelector('#injected-hidden-value')&&document.querySelector('#formSubmit input[name="find_user"]').value===value;}finally{HTMLFormElement.prototype.submit=original;}
});assert(safe);pass('Hidden form values remain text rather than injected HTML');
await submit({action:'Users.setupUsers'});await page.locator('.setupUsers').waitFor();
await page.locator('.btnAdd').click();await page.locator('#addUser').waitFor();
await page.locator('#user_name').fill('browser_tester');await page.locator('#user_first_name').fill('Browser');await page.locator('#user_last_name').fill('Tester');await page.locator('#user_email').fill('browser@example.test');await page.locator('#user_role').selectOption('User');
await page.locator('.btnSave').click();await page.locator('.setupUsers').waitFor();
await page.getByText('Browser',{exact:true}).waitFor();pass('Administrative account form saves through asynchronous validation');
assert((await page.locator('.toast-body').innerText()).includes('invitation'));pass('Failed invitation is visible after returning to the user list');
const row=page.locator('tr.user-row').filter({hasText:'browser_tester'});await row.click();await page.locator('.userSummary').waitFor();pass('Clicking a user opens the repaired summary');
await page.locator('.summary-edit').click();await page.locator('#editUser').waitFor();
const failUsage=async route=>{
const data=route.request().postData()||'';
if(data.includes('Users.userActive'))await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({ok:false,error:'Deliberate usage-check failure'})});else await route.continue();
};
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');
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();
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();
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');
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');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=verifyemail');await page.getByRole('heading',{name:'Verify your email'}).waitFor();await page.getByLabel('Email',{exact:true}).fill('unknown@example.test');await page.getByRole('button',{name:'Send verification link'}).click();await page.getByText('If an active account needs verification',{exact:false}).waitFor();pass('Verification request page gives a neutral response in the browser');
assert.deepEqual(errors,[]);pass('No unexpected browser JavaScript errors occurred');console.log(checks.length+' browser checks passed.');
}catch(error){await page.screenshot({path:path.join(__dirname,'.local','browser-failure.png'),fullPage:true});console.error('PAGE ERRORS:',errors);throw error;}
finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+11
View File
@@ -0,0 +1,11 @@
import os
import subprocess
from application_harness import Application,ROOT
app=Application()
try:
env=os.environ.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)
finally:app.close()
+65
View File
@@ -0,0 +1,65 @@
import json
import secrets
import subprocess
from application_harness import Application,ROOT,PHP
app=Application();workers=[]
def race(operation,records,extra=[]):
group=[]
for record in records:
process=subprocess.Popen(PHP+['tests/maintenance_worker.php',operation,app.database,str(record)]+list(map(str,extra)),cwd=ROOT,env=app.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
workers.append(process);group.append(process)
for process in group:
if process.stdout.readline().strip()!='READY':raise AssertionError(process.stderr.read())
for process in group:process.stdin.write('go\n');process.stdin.flush()
results=[]
for process in group:
output,error=process.communicate(timeout=20)
if process.returncode:raise AssertionError(error+output)
results.append(json.loads(output))
return results
try:
app.login()
app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Concurrent',dropdowntype_title='Concurrent',dropdowntype_table='*none',dropdowntype_column='')
kind=app.fixture('inspect')['dropdowntypes'][0]['dropdowntype_serial']
ids=[]
for value in ['First','Second']:
app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value=value)
ident=app.fixture('inspect')['dropdowns'][-1]['dropdown_serial'];ids.append(ident);app.post('Dropdowns.updateDropdownActive',dropdown_serial=ident,dropdown_checked='true')
result=race('default',ids,[kind])
assert all(item['ok'] for item in result),result
assert sum(int(row['dropdown_default']) for row in app.fixture('inspect')['dropdowns'])==1
print('PASS: Concurrent dropdown default changes preserve one default.')
app.post('Roster.workspace',step='club',club_serial=0,club_name='Concurrent ledger')
club=app.fixture('inspect')['clubs'][0]['club_serial']
app.post('Roster.workspace',step='member',club_serial=club,identifier='testadmin',membership_status='active',joined_on='2026-01-01')
app.post('Roster.workspace',step='season',club_serial=club,season_name='Season',starts_on='2026-01-01',ends_on='2026-12-31')
data=app.fixture('inspect');membership=data['club_memberships'][0]['membership_serial'];season=data['club_seasons'][0]['season_serial']
app.post('Roster.workspace',step='charge',club_serial=club,membership_serial=membership,season_serial=season,amount='100.00')
due=app.fixture('inspect')['club_dues'][0]['due_serial']
result=race('payment',[due,due],[club,'80.00'])
assert sorted(row['ok'] for row in result)==[False,True],result
assert len(app.fixture('inspect')['club_payments'])==1
print('PASS: Concurrent payments cannot exceed the outstanding balance.')
payment=app.fixture('inspect')['club_payments'][0]['payment_serial']
result=race('reverse',[payment,payment],[club,secrets.token_hex(32)])
assert all(row['ok'] for row in result),result
assert len(app.fixture('inspect')['club_payment_reversals'])==1
print('PASS: Concurrent reversal retries create only one reversal.')
result=race('adjust',[due,due],[club,secrets.token_hex(32)])
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')
assert response[0]==200,response[1]
ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='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
print('PASS: Concurrent administrator changes cannot disable the final administrator.')
print('5 concurrency scenarios passed.')
finally:
for process in workers:
if process.poll() is None:process.terminate();process.wait(timeout=10)
app.close()
+39
View File
@@ -0,0 +1,39 @@
import hashlib,json,os,shutil,subprocess,sys,tempfile,zipfile
from pathlib import Path
from application_harness import Application,PHP,ROOT
checks=[]
def check(value,message):
if not value:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
app=Application()
try:
before=app.fixture('fingerprints')
for _ in range(2):subprocess.run(PHP+['bin/migrate-production.php'],cwd=ROOT,env=app.env,check=True,capture_output=True)
check(app.fixture('fingerprints')==before,'Production migration can be rerun without changing existing rows')
result=subprocess.run(PHP+['bin/check-health.php'],cwd=ROOT,env=app.env,capture_output=True)
check(result.returncode==0,'CLI health check confirms database and required schema')
with tempfile.TemporaryDirectory(prefix='hcr-release-check-') as temporary:
output=Path(temporary)/'release.zip'
subprocess.run([sys.executable,'bin/build-release.py',str(output)],cwd=ROOT,check=True,capture_output=True)
with zipfile.ZipFile(output) as archive:
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(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:
candidate=Path('C:/Users/JamesRichie/AppData/Local/Temp/hcr-auth-mariadb/mariadb-11.4.5-winx64/bin/mariadb-dump.exe')
if candidate.is_file():binary=str(candidate)
if not binary:raise RuntimeError('Install mariadb-dump to verify backup tooling.')
env=app.env.copy();env['HCR_DUMP_BINARY']=binary
result=subprocess.run(PHP+['bin/backup-database.php',temporary],cwd=ROOT,env=env,capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
metadata=json.loads(result.stdout);backup=Path(metadata['backup'])
check(backup.is_file() and hashlib.sha256(backup.read_bytes()).hexdigest()==metadata['sha256'],'Backup scheduler entry point produces a complete checksummed SQL backup')
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')
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()
print(str(len(checks))+' deployment tooling checks passed.')
+32
View File
@@ -12,14 +12,46 @@ if ($action === 'create') {
$db->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$db->exec("USE `{$name}`");
require __DIR__ . '/../tables/users.php'; $db->exec($SQL);
foreach(['dropdowntypes','dropdowns','notes','statuses','reports','alerts','popovers','journal','zipcodes'] as $table){require __DIR__.'/../tables/'.$table.'.php';$db->exec($SQL);}
foreach(glob(__DIR__.'/../views/view_*.php') as $view){require $view;$db->exec($SQL);}
$stmt=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role) VALUES('testadmin','Test','Administrator','admin@example.test',?,'Administrator')");$stmt->execute([password_hash('Admin test password 123!',PASSWORD_DEFAULT)]);
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/002_roster.sql')) as $sql){if(trim($sql))$db->exec($sql);}
$db->exec('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP()');
// The CLI migration must also support being run against a fresh users table.
putenv('HCR_DB_NAME=' . $name);
require __DIR__ . '/../bin/migrate-auth.php';
require __DIR__.'/../bin/migrate-production.php';
// Idempotent migration is verified separately by the HTTP test runner.
echo json_encode(['database' => $name]);
exit;
}
$db->exec("USE `{$name}`");
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));
}echo json_encode($result);exit;
}
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=[];
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==='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;
}
if($action==='paging'){
$tip=$db->prepare('INSERT INTO popovers(popover_name,popover_title,popover_text) VALUES(?,?,?)');
$user=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role) VALUES(?,?,?,?,?,'User')");
$hash=password_hash('Paging test password!',PASSWORD_DEFAULT);
for($i=1;$i<=41;$i++){$tip->execute(['Paging '.sprintf('%02d',$i),'Paging '.$i,'Text']);$user->execute(['paging'.$i,'Paging','Family'.$i,'paging'.$i.'@example.test',$hash]);}
echo json_encode(['added'=>41]);exit;
}
if ($action === 'revoke') {
$db->exec("UPDATE users SET user_auth_version = user_auth_version + 1 WHERE user_name = 'httpuser'");
exit;
+25
View File
@@ -0,0 +1,25 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$checks=0;
function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException($message);$checks++;}
function invalid(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}verify(false,$message);}
foreach([0=>0,1=>0,20=>0,21=>20,41=>40] as $count=>$offset){$p=Pagination::calculate($count,20,1,'last');verify($p['offset']===$offset,'Last page offset for '.$count);verify($p['start']===($count?$offset+1:0),'Start count for '.$count);}
verify(Pagination::calculate(41,20,99)['page']===3,'Page is clamped after records are removed');
verify(Pagination::calculate(0,20,1,'next')['offset']===0,'Empty next page cannot have a negative offset');
verify(Pagination::calculate(200,-1,1)['size']===1,'Negative page size is clamped');
verify(Pagination::calculate(200,99999,1)['size']===100,'Large page size is bounded');
$_SESSION=[];Pagination::session('users',100,20,'last');$p=Pagination::session('notes',100,20);verify($p['page']===1,'Pagination does not leak between screens');
foreach(['0','-1','1-2','1 OR 1=1',[],null] as $id)invalid(fn()=>MaintenanceService::id($id),'Invalid record ID is rejected');
verify(MaintenanceService::id('12')===12,'Positive ID accepted');
foreach(['false'=>false,'true'=>true,'0'=>false,'1'=>true] as $input=>$expected)verify(MaintenanceService::boolean($input)===$expected,'Boolean is parsed strictly');
foreach(['false; DROP TABLE users','1 OR SLEEP(1)','',[],null] as $input)invalid(fn()=>MaintenanceService::boolean($input),'Nonboolean input rejected');
invalid(fn()=>MaintenanceService::usageRule('*function','getSettings'),'Inherited callbacks are not allowed');
invalid(fn()=>MaintenanceService::usageRule('users','user_password_hash'),'Database identifiers are not accepted as usage rules');
MaintenanceService::usageRule('*none','');verify(true,'Code-owned no-reference policy accepted');
foreach(['0'=>0,'1'=>100,'3.14'=>314,'10.1'=>1010,'9999999.99'=>999999999] as $input=>$expected)verify(RosterService::cents((string)$input)===$expected,'Currency parsed without floating point');
foreach(['-1','1.001','1e2','NaN','01','10000000'] as $input)invalid(fn()=>RosterService::cents($input),'Invalid currency rejected');
invalid(fn()=>RosterService::date('2026-02-30'),'Invalid calendar date rejected');
foreach(['=SUM(A1:A2)',' +CMD','@formula',"\tformula",'-123'] as $cell)verify(str_starts_with(RosterReports::csvText($cell),"'"),'CSV formula prefix neutralized');
verify(RosterReports::csvText('Plain member')==='Plain member','Ordinary CSV text preserved');
echo $checks." maintenance unit checks passed.\n";
+17
View File
@@ -0,0 +1,17 @@
<?php
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'];
echo "READY\n";flush();fgets(STDIN);
try{
$service=new MaintenanceService();
if(($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]);
elseif(($argv[1]??'')==='adjust')(new LedgerService())->adjust((int)$argv[4],(int)$argv[3],'90.00','Concurrent correction',$argv[5]);
else throw new RuntimeException('Unknown test operation.');
echo json_encode(['ok'=>true]),"\n";
}catch(AppException $e){echo json_encode(['ok'=>false,'status'=>$e->status]),"\n";}
+9
View File
@@ -0,0 +1,9 @@
import subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:
result=subprocess.run(PHP+['tests/production_security.php'],cwd=ROOT,env=app.env)
if result.returncode:raise SystemExit(result.returncode)
result=subprocess.run(PHP+['tests/runtime_privileges.php'],cwd=ROOT,env=app.env)
if result.returncode:raise SystemExit(result.returncode)
finally:app.close()
+91
View File
@@ -0,0 +1,91 @@
<?php
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;
$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);}
class ReviewAuth extends AuthService {
public array $mail=[];public $onDelivery=null;public $onFind=null;
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
public function findUser(string $identifier):?array {$user=parent::findUser($identifier);if($this->onFind){$call=$this->onFind;$this->onFind=null;$call();}return $user;}
protected function deliverResetEmail(array $user,string $url):bool {$this->mail[]=$url;if($this->onDelivery){$call=$this->onDelivery;$this->onDelivery=null;$call();}return true;}
public function token():string {parse_str(parse_url(end($this->mail),PHP_URL_QUERY),$query);return $query['token'];}
}
class ReviewVerification extends EmailVerification {
public string $url='';public bool $mailWorks=true;
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
protected function deliver(array $user,string $url):bool {$this->url=$url;return $this->mailWorks;}
public function token():string {parse_str(parse_url($this->url,PHP_URL_QUERY),$query);return $query['token'];}
}
$auth=new ReviewAuth();$service=new MaintenanceService();$password='Production test password!';
$input=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_password'=>$password,'user_password_confirm'=>$password];$auth->register($input);
$id=(int)$auth->findUser('securitymember')['user_serial'];
$edit=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_role'=>'User','user_active'=>'true'];
$auth->requestReset('securitymember');$token=$auth->token();$edit['user_email']='changed@example.test';$service->updateUser($id,$edit);
verify(!$auth->resetPassword($token,$password,$password),'Old-email reset link is rejected after email change');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$service->setUserActive($id,false);$service->setUserActive($id,true);
verify(!$auth->resetPassword($token,$password,$password),'Disable/reactivate does not revive a reset link');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$edit['user_role']='Guest';$service->updateUser($id,$edit);
verify(!$auth->resetPassword($token,$password,$password),'Role change invalidates existing reset links');
$edit['user_role']='User';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');
$auth->onFind=function()use($service,$id,&$edit){$edit['user_email']='raced@example.test';$service->updateUser($id,$edit);};
verify(!$auth->requestReset('securitymember'),'Reset issuance rejects a user snapshot superseded by an account edit');
verify((int)$db->query('SELECT COUNT(*) FROM password_resets')->fetchColumn()===0,'Stale reset issuance leaves no usable token');
$auth->onDelivery=function()use($service,$id,&$edit){$edit['user_email']='later@example.test';$service->updateUser($id,$edit);};$auth->requestReset('securitymember');
verify(!$auth->resetPassword($auth->token(),$password,$password),'Email change during delivery invalidates an already-created reset token');
$verification=new ReviewVerification();$db->exec('DELETE FROM auth_attempts');
verify($verification->request('securitymember'),'Verification link is generated for an unverified account');
$token=$verification->token();verify(!str_contains(json_encode($db->query('SELECT * FROM email_verifications')->fetchAll()),$token),'Verification token is stored only as a hash');
verify(!$verification->confirm($token,'wrong password'),'Mailbox verification also requires the account password');
verify($verification->confirm($token,$password),'Correct verification link and password verify email ownership');
verify(!$verification->confirm($token,$password),'Verification links cannot be reused');
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Verified ownership is stored');
$edit['user_email']='unverified@example.test';$service->updateUser($id,$edit);
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing email clears ownership verification');
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$db->exec('UPDATE email_verifications SET expires_at=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 SECOND)');
verify(!$verification->confirm($token,$password),'Expired email verification link is rejected');
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$edit['user_email']='newmail@example.test';$service->updateUser($id,$edit);
verify(!$verification->confirm($token,$password),'Email edit invalidates old verification links');
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=false;try{$verification->request('securitymember');throw new LogicException('Expected mail failure');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'could not be delivered'),'Verification mail failure is detected');}
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();
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;
$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);
$roster->saveMember($club,0,'securitymember','active','2026-01-01');$membership=(int)$db->query('SELECT membership_serial FROM club_memberships')->fetchColumn();$roster->addSeason($club,'Season','2026-01-01','2026-12-31');$season=(int)$db->query('SELECT season_serial FROM club_seasons')->fetchColumn();$roster->charge($club,$membership,$season,'100.00');$due=(int)$db->query('SELECT due_serial FROM club_dues')->fetchColumn();$ledger=new LedgerService();
$key=bin2hex(random_bytes(32));$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');
verify((int)$db->query('SELECT COUNT(*) FROM club_payments')->fetchColumn()===1,'Payment retries preserve exactly one original payment');
$key=bin2hex(random_bytes(32));$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);
$row=$roster->dues($club)[0];verify((int)$row['original_amount_cents']===10000&&(int)$row['amount_cents']===8000&&(int)$row['paid_cents']===3000,'Dues corrections preserve original amount and calculate effective balance');
verify((int)$db->query('SELECT COUNT(*) FROM club_due_adjustments')->fetchColumn()===1,'Dues correction retries are idempotent');
rejects(fn()=>$ledger->adjust($club,$due,'20.00','Too low',bin2hex(random_bytes(32))),'Dues cannot be corrected below received payments');
$payment=(int)$db->query('SELECT payment_serial FROM club_payments')->fetchColumn();$key=bin2hex(random_bytes(32));$ledger->reverse($club,$payment,'Incorrect check recorded',$key);$ledger->reverse($club,$payment,'Incorrect check recorded',$key);
verify((int)$db->query('SELECT COUNT(*) FROM club_payment_reversals')->fetchColumn()===1,'Payment reversal retries are idempotent');
$row=$roster->dues($club)[0];verify((int)$row['paid_cents']===0&&(int)$db->query('SELECT amount_cents FROM club_payments')->fetchColumn()===3000,'Reversal restores balance and preserves the original payment');
rejects(fn()=>$ledger->reverse($club,$payment,'Again',bin2hex(random_bytes(32))),'A payment cannot be reversed twice');
rejects(fn()=>$ledger->adjust($club,$due,'90.00','',bin2hex(random_bytes(32))),'Financial corrections require a reason');
$ledger->payment($club,$due,'80.00','2026-01-02',bin2hex(random_bytes(32)),'transfer','replacement');
verify((int)$roster->dues($club)[0]['paid_cents']===8000,'Replacement payment settles corrected dues');
rejects(fn()=>$ledger->payment($club,$due,'0.01','2026-01-02',bin2hex(random_bytes(32))),'Corrected paid dues still reject overpayment');
$history=$ledger->history($club);verify(count($history['payments'])===2&&count($history['adjustments'])===1,'History includes original payments, reversal details, and correction');
verify(str_contains((string)$db->query("SELECT journal_entry FROM journal WHERE journal_entry LIKE '%received payment%' LIMIT 1")->fetchColumn(),'amount_cents'),'Financial audit includes the amount and payment identifier');
verify((int)$db->query("SELECT COUNT(*) FROM journal WHERE journal_entry LIKE '%saved membership%' OR journal_entry LIKE '%created season%'")->fetchColumn()===2,'Membership and season writes have audit events');
rejects(fn()=>(new Journal())->clearJournal(),'Application journal clearing is prohibited');
$db->exec("CREATE TRIGGER reject_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Deliberate audit failure'");
try{$ledger->adjust($club,$due,'90.00','Audit should fail',bin2hex(random_bytes(32)));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify((int)$roster->dues($club)[0]['amount_cents']===8000,'Financial write rolls back if its mandatory audit fails');}
$before=$auth->findUser('securitymember')['user_first_name'];try{$service->updateUser($id,array_replace($edit,['user_first_name'=>'Should rollback']));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify($auth->findUser('securitymember')['user_first_name']===$before,'Account edit rolls back if its audit fails');}
$db->exec('DROP TRIGGER reject_audit');
verify(!Transport::https(['REMOTE_ADDR'=>'203.0.113.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Untrusted forwarding headers cannot mark a request secure');
verify(Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Explicitly trusted proxy can report HTTPS');
verify(!Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https,http'],'127.0.0.1'),'Ambiguous forwarded protocol is rejected');
verify(Transport::https(['HTTPS'=>'on']),'Direct HTTPS remains supported');
putenv('HCR_ENVIRONMENT=Production');putenv('HCR_DB_HOST=remote.example.test');putenv('HCR_DB_TLS_CA');
try{(new Base())->connect();throw new LogicException('Expected transport rejection');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'verified TLS'),'Remote production database connections fail closed without a trusted CA');}
echo "$checks production security and ledger checks passed.\n";
ob_end_flush();
+167
View File
@@ -0,0 +1,167 @@
import io
import json
import secrets
import zipfile
from xml.etree import ElementTree
from application_harness import Application
checks=[]
def check(condition,message,details=b''):
if not condition:raise AssertionError(message+': '+details[:700].decode(errors='replace'))
checks.append(message)
def expect(response,status,message):
check(response[0]==status,message,response[1]);return response[1]
app=Application()
try:
app.login()
csrf=app.token
expect(app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Unsafe',dropdowntype_title='Unsafe',dropdowntype_table='*function',dropdowntype_column='getSettings'),422,'Callback metadata is rejected on write')
expect(app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Colors',dropdowntype_title='Colors',dropdowntype_table='*none',dropdowntype_column=''),200,'Safe dropdown type can be created')
kind=app.fixture('inspect')['dropdowntypes'][0]['dropdowntype_serial']
expect(app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value='Green'),200,'Dropdown value can be created')
first=app.fixture('inspect')['dropdowns'][0]['dropdown_serial']
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='0, dropdown_value=123'),422,'SQL payload is rejected as an invalid boolean')
check(app.fixture('inspect')['dropdowns'][0]['dropdown_value']=='Green','Rejected SQL input leaves data unchanged')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='true'),409,'Inactive dropdown cannot become default')
expect(app.post('Dropdowns.updateDropdownActive',dropdown_serial=first,dropdown_checked='true'),200,'Dropdown can be activated')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='true'),200,'Active dropdown can become default')
expect(app.post('Dropdowns.deleteDropdown',dropdown_serial=first),409,'Default dropdown cannot be deleted')
expect(app.post('Dropdowns.deleteDropdowntype',dropdowntype_serial=kind),409,'Type with child dropdowns cannot be deleted')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=99999,dropdown_checked='true'),404,'Wrong dropdown type is rejected')
expect(app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value='Blue'),200,'Second dropdown can be created')
second=app.fixture('inspect')['dropdowns'][-1]['dropdown_serial']
app.post('Dropdowns.updateDropdownActive',dropdown_serial=second,dropdown_checked='true')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=second,dropdown_dropdowntype=kind,dropdown_checked='true'),200,'Default can be switched atomically')
check(sum(int(row['dropdown_default']) for row in app.fixture('inspect')['dropdowns'])==1,'Only one dropdown default remains')
expect(app.post('Dropdowns.deleteDropdown',dropdown_serial=first),200,'Unused nondefault dropdown can be deleted')
unsafe=app.fixture('unsafeRule')['dropdown']
body=expect(app.post('Dropdowns.dropdownActive',dropdown_serial=unsafe),422,'Legacy callback also fails closed when already stored')
check(b'DatabasePassword' not in body and b'SmtpPassword' not in body,'Callback failure does not return settings')
for name in ['Ready','Waiting']:
expect(app.post('Statuses.addStatus',step='add',status_name=name,status_classes='text-primary',status_active='true',status_default='true'),200,'Status '+name+' can be saved')
state=app.fixture('inspect')
check(sum(int(row['status_default']) for row in state['statuses'])==1,'Only one default status remains')
default=next(row['status_serial'] for row in state['statuses'] if row['status_default'])
expect(app.post('Statuses.deleteStatus',status_serial=default),409,'Default status cannot be deleted')
expect(app.post('Statuses.statusActive',status_serial=default),200,'Status usage check no longer queries workorders')
expect(app.post('Statuses.addStatus',step='add',status_name='Broken',status_active='false',status_default='true'),422,'Inactive default status is rejected')
admin=state['users'][0]
expect(app.post('Users.deleteUser',user_serial=admin['user_serial']),409,'Administrator cannot delete own account')
expect(app.post('Users.updateActiveStatus',user_serial=admin['user_serial'],user_active='false'),409,'Last active administrator cannot be disabled')
expect(app.post('Users.editUser',step='update',user_serial=admin['user_serial'],user_name=admin['user_name'],user_first_name='Test',user_last_name='Administrator',user_email=admin['user_email'],user_role='User',user_active='true'),409,'Last administrator cannot be demoted')
details={'step':'add','user_name':'member_one','user_first_name':'First','user_last_name':'Member','user_email':'member@example.test','user_role':'User'}
body=expect(app.post('Users.addUser',**details),200,'Account creation survives invitation delivery failure')
check(bool(json.loads(body)['warning']),'Invitation failure has an explicit warning and resend path')
expect(app.post('Users.addUser',**details),409,'Retry cannot silently create a duplicate account')
member=next(row for row in app.fixture('inspect')['users'] if row['user_name']=='member_one')
expect(app.post('Notes.addNote',step='add',note_type='Other',note_reference=member['user_serial'],note_text='bad'),422,'Note type is validated during save')
expect(app.post('Notes.addNote',step='add',note_type='User',note_reference=999999,note_text='orphan'),409,'Orphan user note is rejected')
expect(app.post('Notes.addNote',step='add',note_type='User',note_reference=member['user_serial'],note_text='<script>text only</script>'),200,'Valid user note is saved')
expect(app.post('Users.deleteUser',user_serial=member['user_serial']),409,'User with note history cannot be deleted')
body=expect(app.request(data={'action':'Users.userSummary','user_serial':member['user_serial'],'csrf_token':csrf}),200,'User summary no longer depends on subscriptions')
check(b'&lt;script&gt;text only&lt;/script&gt;' in body,'Notes are escaped in the summary')
expect(app.post('Users.userActive',user_serial=member['user_serial']),200,'User usage check no longer queries requests')
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='North Woods'),200,'First club can be created')
club_a=app.fixture('inspect')['clubs'][0]['club_serial']
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='South Woods'),200,'Second club can be created')
club_b=app.fixture('inspect')['clubs'][-1]['club_serial']
for club in [club_a,club_b]:
expect(app.post('Roster.workspace',step='season',club_serial=club,season_name='2026 Season',starts_on='2026-01-01',ends_on='2026-12-31'),200,'Club season can be created')
expect(app.post('Roster.workspace',step='season',club_serial=club_a,season_name='Invalid',starts_on='2026-12-31',ends_on='2026-01-01'),422,'Reversed season dates are rejected')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Unverified accounts cannot receive active memberships')
app.fixture('verifyEmail','member_one')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),200,'Account can be assigned to a club')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Duplicate club membership is rejected')
state=app.fixture('inspect');membership=state['club_memberships'][0]['membership_serial'];season_a=state['club_seasons'][0]['season_serial'];season_b=state['club_seasons'][1]['season_serial']
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_b,amount='100.00'),409,'Cross-club season cannot be used for dues')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.001'),422,'Money rejects fractional cents')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),200,'Season dues can be recorded')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),409,'Duplicate season dues are rejected')
due=app.fixture('inspect')['club_dues'][0]['due_serial'];key=secrets.token_hex(32)
payment=dict(step='payment',club_serial=club_a,due_serial=due,amount='3.14',paid_on='2026-01-02',request_key=key)
expect(app.post('Roster.workspace',**payment),200,'Received payment can be recorded')
expect(app.post('Roster.workspace',**payment),200,'Repeated payment submission is idempotent')
check(len(app.fixture('inspect')['club_payments'])==1,'Payment retry does not double-charge the ledger')
expect(app.post('Roster.workspace',**{**payment,'amount':'100.00','request_key':secrets.token_hex(32)}),409,'Payment above balance is rejected')
for action,signature in [('csv',b'Club,Username'),('excel',b'PK'),('pdf',b'%PDF')]:
body=expect(app.request(data={'action':'RosterReports.'+action,'club_serial':club_a,'csrf_token':csrf}),200,action+' report downloads successfully')
check(body.startswith(signature),action+' report has the correct file format')
if action=='excel':
with zipfile.ZipFile(io.BytesIO(body)) as archive:
root=ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml'))
check(not root.findall('.//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}f'),'Spreadsheet cells do not contain formulas')
body=expect(app.post('Users.editUser',step='update',user_serial=member['user_serial'],user_name='member_one',user_first_name='=HYPERLINK("unsafe")',user_last_name='Updated',user_email='member@example.test',user_role='User',user_active='true'),200,'Administrator can edit first and last names')
body=app.request(data={'action':'RosterReports.csv','club_serial':club_a,'csrf_token':csrf})[1]
check(b"'=HYPERLINK" in body,'CSV formula text is neutralized')
# 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.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')
app.logout();app.login('http@example.test','HTTP long password!')
body=expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),200,'Member can view assigned club')
check(b'South Woods' not in body,'Other clubs are absent from member navigation')
check(b'$100.00' not in body,'Member cannot view another member dues')
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_b)),403,'Member cannot access another club by changing its ID')
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='Unauthorized'),403,'Member cannot create clubs')
expect(app.post('RosterReports.csv',club_serial=club_a),403,'Member cannot download administrator exports')
expect(app.post('Users.deleteUser',user_serial=member['user_serial']),403,'Member cannot delete accounts')
# Keep that member session alive while a separate administrator session changes
# membership/club status; access must be rechecked on each request.
member_client=app.client
import urllib.request,http.cookiejar
app.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
app.login();csrf=app.token;admin_client=app.client
http_membership=next(row['membership_serial'] for row in app.fixture('inspect')['club_memberships'] if row['user_serial']==next(user['user_serial'] for user in app.fixture('inspect')['users'] if user['user_name']=='httpuser'))
expect(app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='inactive',joined_on='2026-01-01'),200,'Membership can be made inactive while preserving history')
app.client=member_client
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Inactive membership immediately loses club access in an existing session')
app.client=admin_client
app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='active',joined_on='2026-01-01')
expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='archived'),200,'Club can be archived without deleting its history')
app.client=member_client
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Archived club is inaccessible to ordinary members')
app.client=admin_client
expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='active'),200,'Archived club can be reactivated')
check(len(app.fixture('inspect')['club_payments'])==1,'Archiving and reactivation preserve the payment ledger')
body=expect(app.request(data={'action':'Journal.viewJournal','csrf_token':csrf}),200,'Journal page loads after authentication events')
check(b'logged in' in body,'Authentication writes journal entries')
for action in ['Users.setupUsers','Dropdowns.setupDropdowntypes','Statuses.setupStatuses','Alerts.setupAlerts','Popovers.setupPopovers','Journal.viewJournal']:
expect(app.request(data={'action':action,'pagination':'last','csrf_token':csrf}),200,action+' handles last page without SQL errors')
body=expect(app.post('getZipcode',zip_code='12345'),200,'ZIP lookup uses a valid route and JSON response')
json.loads(body)
app.fixture('paging')
from lxml import html as parser
import re
response=app.request(data={'action':'Popovers.setupPopovers','pagination':'last','csrf_token':csrf})
body=expect(response,200,'Last-page query works with 41 real records')
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 41 to 41 of 41' in text,'Last page starts at record 41 rather than record 22')
body=app.request(data={'action':'Users.setupUsers','csrf_token':csrf})[1]
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 1 to 20 of 44' in text,'User pagination is independent of popover pagination')
body=app.request(data={'action':'Users.setupUsers','find_user':'Family41','csrf_token':csrf})[1]
check(b'Family41' in body and b'paging40@example.test' not in body,'Users can be searched by last name')
body=app.request(data={'action':'Popovers.setupPopovers','pagination':'first','popovers_per_page':'40','csrf_token':csrf})[1]
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 1 to 40 of 41' in text,'Per-page selection is respected')
expect(app.post('Journal.clearJournal'),404,'Journal-clearing endpoint is no longer routable')
expect(app.post('Roster.workspace',step='adjust',club_serial=club_a,due_serial=due,corrected_amount='90.00',reason='HTTP correction',request_key=secrets.token_hex(32)),200,'Dues corrections work through the protected HTTP route')
expect(app.post('Roster.workspace',step='payment',club_serial=club_a,due_serial=due,amount='1.00',paid_on='2026-01-02',request_key=secrets.token_hex(32),payment_method='check',payment_reference='=SUM(1,1)'),200,'Payment references and methods are saved through HTTP')
new_payment=app.fixture('inspect')['club_payments'][-1]['payment_serial']
expect(app.post('Roster.workspace',step='reverse',club_serial=club_a,payment_serial=new_payment,reason='HTTP reversal',request_key=secrets.token_hex(32)),200,'Payment reversals work through the protected HTTP route')
body=expect(app.request(data={'action':'RosterReports.ledger','club_serial':club_a,'csrf_token':csrf}),200,'Financial history exports through HTTP')
check(b"'=SUM(1,1)" in body,'Financial CSV neutralizes formula text in payment references')
check(b'HTTP correction' in body and b'HTTP reversal' in body,'Financial CSV includes correction and reversal reasons')
body=expect(app.request('/?Xaction=verifyemail'),200,'Public email verification page renders')
expect(app.request(data={'action':'verifyemail','step':'confirm','token':'0'*64,'user_password':'wrong'}),403,'Email verification rejects a missing CSRF token')
headers=app.request()[2]
check("script-src 'self'" in headers['Content-Security-Policy'],'Application sends a CSP without inline-script permission')
print('\n'.join('PASS: '+message for message in checks))
print(f'{len(checks)} project HTTP checks passed.')
finally:app.close()
+7
View File
@@ -0,0 +1,7 @@
import json,os,subprocess,sys
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']:
subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
+17
View File
@@ -0,0 +1,17 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();$name=(string)$settings->DatabaseName;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name))throw new RuntimeException('Disposable database required.');
$db=(new Base())->connect();$account='hcr_check_'.bin2hex(random_bytes(6));$password=bin2hex(random_bytes(24));$identity=$db->quote($account)."@'%'";
$db->exec('CREATE USER '.$identity.' IDENTIFIED BY '.$db->quote($password));
try{
$sql=file_get_contents(__DIR__.'/../deploy/runtime-grants.sql.example');$sql=preg_replace('/^--.*$/m','',$sql);$sql=str_replace(['huntingclubroster',"'hcr_runtime'@'app-host.example'"],[$name,$identity],$sql);
foreach(explode(';',$sql) as $statement)if(trim($statement))$db->exec($statement);
$limited=new PDO('mysql:host='.$settings->DatabaseServer.';port='.(int)$settings->DatabasePort.';dbname='.$name,$account,$password,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
$limited->beginTransaction();Audit::required($limited,1,'runtime privilege verification');$limited->commit();$checks=1;
foreach(['DELETE FROM journal','UPDATE club_payments SET amount_cents=amount_cents','DELETE FROM club_dues','ALTER TABLE clubs ADD COLUMN forbidden INT'] as $statement){
try{$limited->exec($statement);throw new RuntimeException('A forbidden runtime operation was allowed.');}catch(PDOException $e){if(!in_array((int)($e->errorInfo[1]??0),[1142,1143],true))throw $e;$checks++;}
}
echo "$checks runtime privilege checks passed: audit inserts work; history deletion, payment edits, and schema changes are denied.\n";
}finally{$db->exec('DROP USER '.$identity);}
+20
View File
@@ -0,0 +1,20 @@
import base64,hashlib,json,os,subprocess,sys
from pathlib import Path
from lxml import etree
ROOT=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'));node=os.environ.get('NODE_TEST_COMMAND','node')
files=[ROOT/'main.php']+[f for directory in ['classes','bin','tests'] for f in (ROOT/directory).glob('*.php')]
for file in files:
result=subprocess.run(php+['-l',str(file)],cwd=ROOT,capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
scripts=list((ROOT/'js').glob('*.js'))
for file in scripts:
result=subprocess.run([node,'--check',str(file)],capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
styles=list((ROOT/'xsl').glob('*.xsl'))
for file in styles:etree.XSLT(etree.parse(str(file)))
dependency=json.loads((ROOT/'browser-dependencies.json').read_text())['jquery']
assert base64.b64encode(hashlib.sha256((ROOT/dependency['file']).read_bytes()).digest()).decode()==dependency['sha256_base64']
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.')