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

65 lines
5.3 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'];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'&lt;script&gt;' 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()