Files
huntingclubroster/tests/member_notes_http.py
T
jric11 b851712968
Security and workflow regressions / regression (push) Has been cancelled
updates from chat
2026-10-21 20:41:57 -04:00

77 lines
7.8 KiB
Python

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'&lt;script&gt;alert(1)&lt;/script&gt;' 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,'&notes_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()