generated from jric11/baseProject
Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled
Security and workflow regressions / regression (push) Has been cancelled
This commit is contained in:
@@ -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
@@ -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})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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";}
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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'<script>text only</script>' 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()
|
||||
@@ -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)
|
||||
@@ -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);}
|
||||
@@ -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.')
|
||||
Reference in New Issue
Block a user