generated from jric11/baseProject
This commit is contained in:
@@ -19,7 +19,7 @@ try:
|
||||
names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json'))
|
||||
check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests')
|
||||
check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery')
|
||||
check(all(name in names for name in ['classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators')
|
||||
check(all(name in names for name in ['classes/ClubMemberNotes.php','classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators')
|
||||
check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file')
|
||||
binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump')
|
||||
if not binary:
|
||||
|
||||
@@ -50,10 +50,30 @@ try:
|
||||
check(save('member',a,ids['clubA_admina2'],membership_status='inactive',membership_role='member',joined_on='2026-02-01')[0]==403,'Club administrator cannot edit protected administrator memberships')
|
||||
sid=next(c.value for h in app.client.handlers if hasattr(h,'cookiejar') for c in h.cookiejar if c.name=='HUNTINGCLUBROSTER');app.fixture('sessionAge',sid,'','','301')
|
||||
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Membership edits require recent MFA on the new endpoint')
|
||||
payment=app.fixture('inspect')['club_payments'][0]['payment_serial']
|
||||
check(save('adjust',a,member,due_serial=neighbor_due,corrected_amount='80.00',reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another member dues in the same club')
|
||||
check(save('adjust',a,member,due_serial=foreign_due,corrected_amount='80.00',reason='Wrong club',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another club dues')
|
||||
check(save('adjust',a,member,due_serial=due,corrected_amount='24.00',reason='Below paid',request_key=secrets.token_hex(32))[0]==409,'Correction cannot reduce dues below received payments')
|
||||
adjustment_key=secrets.token_hex(32)
|
||||
status,result=save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)
|
||||
check(status==200 and 'Roster.memberSummary' in result['redirect'],'Correction saves and returns to the selected member summary')
|
||||
check(save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)[0]==200 and len(app.fixture('inspect')['club_due_adjustments'])==1,'Correction retries create only one adjustment')
|
||||
check(save('reverse',a,neighbor,payment_serial=payment,reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Reversal rejects another member payment in the same club')
|
||||
app.fixture('mfaTestAuditFailure','on')
|
||||
failed,_=save('reverse',a,member,payment_serial=payment,reason='Audit failure',request_key=secrets.token_hex(32))
|
||||
app.fixture('mfaTestAuditFailure','off')
|
||||
check(failed==500 and len(app.fixture('inspect')['club_payment_reversals'])==0,'Reversal rolls back completely when mandatory audit fails')
|
||||
reversal_key=secrets.token_hex(32)
|
||||
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200,'Payment reverses from the member summary')
|
||||
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200 and len(app.fixture('inspect')['club_payment_reversals'])==1,'Reversal retries create only one reversal')
|
||||
check(save('reverse',a,member,payment_serial=payment,reason='Repeat',request_key=secrets.token_hex(32))[0]==409,'Already reversed payment cannot be reversed again')
|
||||
_,summary,_=app.request(url(a,member));check(b'$80.00' in summary and b'$0.00' in summary and b'Wrong receipt' in summary and b'Corrected season rate' in summary,'Summary reflects corrected dues, reversed payment and preserved reasons')
|
||||
app.logout();app.login('membera@example.test','Tenant test password 123!')
|
||||
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Ordinary member receives no action dialogs')
|
||||
check(save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a summary payment request')
|
||||
check(save('charge',a,member,season_serial=season,amount='1.00')[0]==403,'Ordinary member cannot forge a dues request')
|
||||
check(save('adjust',a,member,due_serial=due,corrected_amount='50.00',reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a dues correction')
|
||||
check(save('reverse',a,member,payment_serial=payment,reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a payment reversal')
|
||||
app.logout();app.login('adminb@example.test','Tenant test password 123!')
|
||||
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Other club administrator cannot modify the membership')
|
||||
app.logout();app.login();app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import hashlib,json,urllib.request,urllib.parse,urllib.error
|
||||
from application_harness import Application
|
||||
from lxml import html
|
||||
app=Application();checks=0
|
||||
def check(ok,message):
|
||||
global checks
|
||||
assert ok,message
|
||||
checks+=1;print('PASS:',message)
|
||||
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
|
||||
def save(step,club,member,**extra):
|
||||
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
|
||||
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
|
||||
try:
|
||||
with app.client.open(req) as r:return r.status,json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read()
|
||||
try:result=json.loads(body)
|
||||
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
|
||||
return e.code,result
|
||||
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
|
||||
def version(text):return hashlib.sha256(text.encode()).hexdigest()
|
||||
try:
|
||||
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];shared=ids['clubA_shared']
|
||||
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL NOTE')
|
||||
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
|
||||
app.logout();app.login('admina@example.test','Tenant test password 123!')
|
||||
text='Private deletion test <script>neverRun()</script>'
|
||||
save('note_add',a,member,note_text=text);note=notes(member)[0];nid=note['note_serial']
|
||||
def delete(club=a,membership=member,**extra):return save('note_delete',club,membership,**{'note_serial':nid,'note_version':version(text),**extra})
|
||||
_,page,_=app.request(url(a,member));doc=html.fromstring(page)
|
||||
forms=doc.xpath('//form[input[@name="step" and @value="note_delete"]]')
|
||||
check(len(forms)==1 and forms[0].xpath('.//button[text()="Confirm deletion"]') and b'<script>' in page,'Deletion has a separate confirmation form with escaped note preview')
|
||||
check(delete(csrf_token='invalid')[0]==403,'Deletion requires valid CSRF')
|
||||
check(delete(membership=shared)[0]==404,'Deletion is scoped to the selected membership')
|
||||
check(delete(note_serial=global_note['note_serial'])[0]==404,'Club deletion cannot remove a global user note')
|
||||
check(delete(note_version='')[0]==409,'Deletion requires the displayed note version')
|
||||
save('note_edit',a,member,note_serial=nid,note_text='Changed by another administrator',note_version=version(text))
|
||||
check(delete()[0]==409 and len(notes(member))==1,'Stale confirmation cannot delete an edited note')
|
||||
text='Changed by another administrator'
|
||||
app.fixture('mfaTestAuditFailure','on');before=app.fixture('inspect')['notes']
|
||||
status,_=delete();app.fixture('mfaTestAuditFailure','off')
|
||||
check(status==500 and before==app.fixture('inspect')['notes'],'Audit failure rolls back deletion')
|
||||
app.logout();app.login('adminb@example.test','Tenant test password 123!')
|
||||
check(delete()[0]==403,'Another club administrator cannot delete the note')
|
||||
app.logout();app.login('membera@example.test','Tenant test password 123!')
|
||||
check(delete()[0]==403,'Ordinary member cannot forge a deletion')
|
||||
check(app.post('Notes.deleteNote',note_serial=nid)[0]==403,'Member cannot use the legacy global deletion route')
|
||||
app.logout();app.login('admina2@example.test','Tenant test password 123!')
|
||||
status,result=delete()
|
||||
check(status==200 and not notes(member) and '#admin-notes' in result['redirect'],'Shared club administrator can delete another administrator note and return to notes')
|
||||
events=[j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']]
|
||||
check(len(events)==1 and str(events[0]['journal_origin'])==str(ids['admina2']) and all(('"'+key+'":'+str(value)) in events[0]['journal_entry'] for key,value in [('club',a),('membership',member),('note',nid)]) and events[0]['journal_timestamp'],'Audit retains actor, timestamp, club, membership and note identifiers')
|
||||
check(text not in events[0]['journal_entry'],'Audit does not expose private note text')
|
||||
check(delete()[0]==404 and len([j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']])==1,'Repeated deletion returns not found without duplicate audit')
|
||||
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
|
||||
app.logout();app.login()
|
||||
check(app.post('Notes.deleteNote',note_serial=nid)[0]==404 and len(notes(member))==1,'Even Super Administrator cannot bypass club deletion audit through the global route')
|
||||
check(delete()[0]==200 and not notes(member),'Super Administrator can delete club administrative notes')
|
||||
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
|
||||
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
|
||||
check(delete()[0]==409 and len(notes(member))==1,'Archived club deletion is rejected')
|
||||
print(checks,'member note deletion HTTP checks passed.')
|
||||
finally:
|
||||
app.fixture('mfaTestAuditFailure','off');app.close()
|
||||
@@ -0,0 +1,76 @@
|
||||
import hashlib,json,urllib.request,urllib.parse,urllib.error
|
||||
from application_harness import Application
|
||||
from lxml import html
|
||||
app=Application();checks=0
|
||||
def check(ok,message):
|
||||
global checks
|
||||
assert ok,message
|
||||
checks+=1;print('PASS:',message)
|
||||
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
|
||||
def save(step,club,member,**extra):
|
||||
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
|
||||
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
|
||||
try:
|
||||
with app.client.open(req) as r:return r.status,json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read()
|
||||
try:result=json.loads(body)
|
||||
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
|
||||
return e.code,result
|
||||
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
|
||||
def version(text):return hashlib.sha256(text.encode()).hexdigest()
|
||||
try:
|
||||
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];sharedA=ids['clubA_shared'];sharedB=ids['clubB_shared']
|
||||
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL ACCOUNT NOTE')
|
||||
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
|
||||
app.logout();app.login('admina@example.test','Tenant test password 123!')
|
||||
text='PRIVATE MEMBER NOTE <script>alert(1)</script>\nSecond line'
|
||||
status,result=save('note_add',a,member,note_text=text,note_type='User',note_reference=ids['shared'])
|
||||
note=notes(member)[0]
|
||||
check(status==200 and '#admin-notes' in result['redirect'],'Club administrator adds a note and returns to the member notes section')
|
||||
check(note['note_text']==text and note['note_type']=='ClubMember' and note['note_reference']==member,'Note uses the selected membership and fixed private type despite forged fields')
|
||||
_,body,_=app.request(url(a,member));check(b'<script>alert(1)</script>' in body and b'<script>alert(1)</script>' not in body,'Private notes render as escaped plain text')
|
||||
_,other,_=app.request(url(a,sharedA));check(b'PRIVATE MEMBER NOTE' not in other,'Notes do not leak into another member summary')
|
||||
check(save('note_add',a,member,note_text='')[0]==422,'Empty administrative notes are rejected')
|
||||
check(save('note_add',a,member,note_text='x'*2001)[0]==422,'Oversized administrative notes are rejected')
|
||||
check(save('note_add',a,member,note_text='x',csrf_token='invalid')[0]==403,'Note writes require CSRF')
|
||||
check(save('note_edit',a,sharedA,note_serial=note['note_serial'],note_text='Wrong member',note_version=version(text))[0]==404,'Note IDs cannot be applied to a different membership')
|
||||
check(save('note_edit',a,member,note_serial=global_note['note_serial'],note_text='Wrong type',note_version=version('GLOBAL ACCOUNT NOTE'))[0]==404,'Global account notes cannot be edited through the club notes API')
|
||||
app.logout();app.login('admina2@example.test','Tenant test password 123!')
|
||||
_,body,_=app.request(url(a,member));check(b'PRIVATE MEMBER NOTE' in body,'Another administrator of the shared club can read its notes')
|
||||
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='EDITED PRIVATE NOTE',note_version=version(text))[0]==200,'Another club administrator can edit its note')
|
||||
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Stale overwrite',note_version=version(text))[0]==409 and notes(member)[0]['note_text']=='EDITED PRIVATE NOTE','Stale edits cannot overwrite a newer note')
|
||||
before=app.fixture('inspect')['notes'];app.fixture('mfaTestAuditFailure','on')
|
||||
failed_add,_=save('note_add',a,member,note_text='Must roll back')
|
||||
failed_edit,_=save('note_edit',a,member,note_serial=note['note_serial'],note_text='Must also roll back',note_version=version('EDITED PRIVATE NOTE'))
|
||||
app.fixture('mfaTestAuditFailure','off')
|
||||
check(failed_add==500 and failed_edit==500 and app.fixture('inspect')['notes']==before,'Mandatory audit failures roll back both note additions and edits')
|
||||
save('note_add',a,sharedA,note_text='ALPHA CLUB PRIVATE')
|
||||
app.logout();app.login('adminb@example.test','Tenant test password 123!')
|
||||
check(app.request(url(a,member))[0]==403,'Other club administrator cannot open the protected member summary')
|
||||
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Foreign overwrite',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Other club administrator cannot edit the note')
|
||||
save('note_add',b,sharedB,note_text='BETA CLUB PRIVATE')
|
||||
app.logout();app.login('admina@example.test','Tenant test password 123!')
|
||||
check(app.request(url(b,sharedB))[0]==404,'An administrator with only member access in another club cannot read that club member notes')
|
||||
app.logout();app.login('membera@example.test','Tenant test password 123!')
|
||||
_,body,_=app.request(url(a,member));check(b'EDITED PRIVATE NOTE' not in body and b'Administrative notes' not in body and b'note_serial' not in body,'Member receives no private note content, section, identifiers or edit forms')
|
||||
check(save('note_add',a,member,note_text='Forged')[0]==403 and save('note_edit',a,member,note_serial=note['note_serial'],note_text='Forged',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Member cannot forge add or edit note requests')
|
||||
check(app.post('Notes.editNote',note_serial=note['note_serial'])[0]==403,'Legacy global note endpoints cannot bypass club-note permissions')
|
||||
app.logout();app.login('shared@example.test','Tenant test password 123!')
|
||||
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
|
||||
check(b'ALPHA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' not in beta and b'Administrative notes' not in alpha and b'Administrative notes' not in beta,'A member in multiple clubs cannot see administrative notes in either club')
|
||||
app.logout();app.login()
|
||||
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
|
||||
check(b'ALPHA CLUB PRIVATE' in alpha and b'BETA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' in beta and b'ALPHA CLUB PRIVATE' not in beta,'Super Administrator can read separate club-specific notes for the same person')
|
||||
check(b'GLOBAL ACCOUNT NOTE' not in alpha and b'GLOBAL ACCOUNT NOTE' not in beta,'Global account notes remain separate from club administrative notes')
|
||||
_,account,_=app.post('Users.userSummary',user_serial=ids['shared']);check(b'GLOBAL ACCOUNT NOTE' in account and b'ALPHA CLUB PRIVATE' not in account and b'BETA CLUB PRIVATE' not in account,'Global user summary does not mix in club membership notes')
|
||||
check(save('note_add',a,member,note_text='😀'*2000)[0]==200,'Server accepts the full Unicode note limit within its transport budget')
|
||||
for index in range(26):save('note_add',a,member,note_text='Paged admin note '+str(index))
|
||||
_,body,_=app.request(url(a,member,'¬es_page=9999'));rows=html.fromstring(body).xpath('//section[contains(@class,"member-admin-notes")]//tbody/tr')
|
||||
check(len(rows)==len(notes(member))-25 and b'Page 2 of 2' in body,'Administrative notes use bounded, clamped membership-specific paging')
|
||||
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
|
||||
_,body,_=app.request(url(a,member));check(b'Administrative notes' in body and b'data-club-step="note_' not in body,'Archived club notes remain readable to administrators without write dialogs')
|
||||
check(save('note_add',a,member,note_text='Archived write')[0]==409,'Archived club note writes are rejected')
|
||||
print(checks,'member note HTTP checks passed.')
|
||||
finally:
|
||||
app.fixture('mfaTestAuditFailure','off');app.close()
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from lxml import html
|
||||
from application_harness import Application
|
||||
app=Application();checks=0
|
||||
def check(ok,message):
|
||||
@@ -21,11 +22,11 @@ try:
|
||||
check(b'Own reversed receipt' in body and b'OWN-RECEIPT-32' in body,'Payment history preserves reversed receipts and their reasons')
|
||||
check(b'<script>unsafe</script>' in body and b'<script>unsafe</script>' not in body,'Correction reasons are escaped safely')
|
||||
check(b'NEIGHBOR-PRIVATE' not in body and b'FOREIGN-PRIVATE' not in body,'Selected member summary excludes neighboring members and other clubs')
|
||||
check(body.count(b'OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently')
|
||||
check(html.fromstring(body).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently')
|
||||
links=re.findall(rb'href="([^"]+)"',body)
|
||||
check(any(b'payments_page=2' in link and b'membership_serial='+str(ids['clubA_membera']).encode() in link and b'Roster.memberSummary' in link for link in links),'History pager links retain both club and membership scope')
|
||||
status,last,_=summary(ids['clubA'],ids['clubA_membera'],'&payments_page=9999&dues_page=9999')
|
||||
check(status==200 and last.count(b'OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records')
|
||||
check(status==200 and html.fromstring(last).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records')
|
||||
check(b'$3,150.00' in last and b'$31.00' in last and b'$3,119.00' in last,'Summary totals remain identical on later history pages')
|
||||
check(summary(ids['clubB'],ids['clubA_membera'])[0]==404,'Membership IDs cannot be paired with another club')
|
||||
check(summary(ids['clubA'],'invalid')[0]==422,'Invalid membership identifiers are rejected')
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@ from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
|
||||
if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.')
|
||||
for suite in ['tests/source_checks.py','tests/mfa_key_configuration.php','tests/mfa_qr_browser_runner.py','tests/mfa_lifecycle_http.py','tests/mfa_notifications_runner.py','tests/mfa_concurrency.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/club_onboarding_http.py','tests/member_summary_http.py','tests/member_actions_http.py','tests/project_http.py','tests/maintenance_http.py','tests/production_runner.py','tests/concurrency.py','tests/tenants_migration.py','tests/tenant_repairs_runner.py','tests/tenant_repair_concurrency.py','tests/ticket_repairs_http.py','tests/ticket_paging_http.py','tests/tenants_runner.py','tests/tenants_http.py','tests/browser_runner.py','tests/tenants_browser_runner.py','tests/ticket_repairs_browser_runner.py','tests/backup_runner.py','tests/deployment_tools.py','tests/security_capacity_http.py','tests/security_capacity_runner.py','tests/resource_http.py','tests/resource_concurrency.py']:
|
||||
for suite in ['tests/source_checks.py','tests/mfa_key_configuration.php','tests/mfa_qr_browser_runner.py','tests/mfa_lifecycle_http.py','tests/mfa_notifications_runner.py','tests/mfa_concurrency.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/club_onboarding_http.py','tests/member_summary_http.py','tests/member_actions_http.py','tests/member_notes_http.py','tests/member_note_deletion_http.py','tests/project_http.py','tests/maintenance_http.py','tests/production_runner.py','tests/concurrency.py','tests/tenants_migration.py','tests/tenant_repairs_runner.py','tests/tenant_repair_concurrency.py','tests/ticket_repairs_http.py','tests/ticket_paging_http.py','tests/tenants_runner.py','tests/tenants_http.py','tests/browser_runner.py','tests/tenants_browser_runner.py','tests/ticket_repairs_browser_runner.py','tests/backup_runner.py','tests/deployment_tools.py','tests/security_capacity_http.py','tests/security_capacity_runner.py','tests/resource_http.py','tests/resource_concurrency.py']:
|
||||
subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
|
||||
|
||||
@@ -31,7 +31,16 @@ const assert=require('node:assert/strict');
|
||||
if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});await admin.setViewportSize({width:390,height:844});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');await admin.setViewportSize({width:1280,height:720});
|
||||
await admin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();await admin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((await admin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary');
|
||||
await admin.locator('[data-club-step=charge]').click();await admin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});await admin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club');
|
||||
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.getByText('Summary modal payment',{exact:false}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary');
|
||||
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.getByRole('cell',{name:'unspecified / Summary modal payment',exact:true}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary');
|
||||
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=adjust]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).inputValue(),'20.00');await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).fill('15.00');await admin.locator('.club-action-modal.show').getByLabel('Reason',{exact:true}).fill('Summary dues correction');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save correction',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));pass('Member-summary correction modal prefills dues and refreshes totals without leaving the member');
|
||||
const receipt=admin.getByRole('row').filter({hasText:'Summary modal payment'});await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByLabel('Reversal reason',{exact:true}).fill('Summary wrong receipt');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm reversal',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,134.00'));const reversed=admin.getByRole('row').filter({hasText:'Summary modal payment'});assert((await reversed.textContent()).includes('Summary wrong receipt'));assert.equal(await reversed.locator('[data-club-step=reverse]').count(),0);pass('Member-summary reversal supports Cancel, preserves the receipt and removes its reversal button');
|
||||
|
||||
const privateText='Private browser member note\nSecond line';await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Unsaved private draft');await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Unsaved private draft',exact:true}).count(),0);await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill(privateText);await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.locator('.member-admin-notes').getByRole('cell',{name:'Private browser member note Second line',exact:true}).waitFor();assert(new URL(admin.url()).hash==='#admin-notes');pass('Administrative note add modal supports Cancel and saves back to the member notes section');
|
||||
await admin.locator('.member-admin-notes [data-club-step=note_edit]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).inputValue(),privateText);await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Updated private browser note');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).waitFor();pass('Administrative note edit modal prefills and saves the selected note');
|
||||
const privacyContext=await browser.newContext(),privacyPage=await privacyContext.newPage();await login(privacyPage,'membera@example.test');await privacyPage.getByRole('link',{name:'Tenant Membera',exact:true}).click();assert.equal(await privacyPage.locator('.member-admin-notes').count(),0);assert(!(await privacyPage.content()).includes('Updated private browser note'));assert.equal(await privacyPage.locator('.memberSummary [name=note_text]').count(),0);await privacyContext.close();pass('Member browser receives no administrative note section, text or hidden note fields');
|
||||
await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).waitFor();assert((await admin.locator('.club-action-modal.show').innerText()).includes('Updated private browser note'));await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).count(),1);pass('Note deletion confirmation shows selected note and Cancel preserves it');
|
||||
await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).click()]);assert.equal(await admin.locator('.member-admin-notes [data-club-step=note_delete]').count(),0);assert(!(await admin.content()).includes('Updated private browser note'));assert(new URL(admin.url()).hash==='#admin-notes');pass('Confirmed note deletion refreshes summary and removes note and hidden dialogs');
|
||||
|
||||
await admin.getByRole('link',{name:'Back to club roster',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();
|
||||
await admin.locator('header [data-club-step=invite]').click();const invite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});await invite.getByLabel('Invitation email').fill('invitee@example.test');await invite.getByRole('button',{name:'Send club invitation',exact:true}).click();await admin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly');
|
||||
await login(member,'invitee@example.test');assert(await member.getByText('You are not assigned to an active club yet.',{exact:false}).count());pass('Accounts without a club retain the empty workspace and invitation access');await member.goto(base+'/main.php?Xaction=Roster.invitations');await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();await member.getByRole('button',{name:'Accept invitation',exact:true}).click();await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace');
|
||||
|
||||
Reference in New Issue
Block a user