Files
huntingclubroster/tests/tenants_http.py
T

65 lines
11 KiB
Python

import json,secrets,subprocess
from application_harness import Application,PHP,ROOT
app=Application();checks=[]
def check(ok,message):
if not ok:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def expect(response,status,message):
check(response[0]==status,message+' (HTTP '+str(response[0])+')');return response[1]
def login(who):app.logout();app.login(who+'@example.test','Tenant test password 123!')
def workspace(club):return app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(club))
try:
ids=app.fixture('tenantSeed');a,b=ids['clubA'],ids['clubB'];app.login()
before=app.fixture('fingerprints');subprocess.run(PHP+['bin/migrate-tenants.php'],cwd=ROOT,env=app.env,check=True,capture_output=True);check(app.fixture('fingerprints')==before,'Tenant migration is idempotent without changing existing rows or promoting accounts')
for club,label,member in [(a,'Alpha',ids['clubA_membera']),(b,'Beta',ids['clubB_memberb'])]:
expect(app.post('Roster.workspace',step='season',club_serial=club,season_name=label+' Season',starts_on='2026-01-01',ends_on='2026-12-31'),200,'Super administrator adds '+label+' season')
season=app.fixture('inspect')['club_seasons'][-1]['season_serial']
expect(app.post('Roster.workspace',step='charge',club_serial=club,membership_serial=member,season_serial=season,amount='100.00'),200,'Super administrator records '+label+' dues')
data=app.fixture('inspect');dueA=next(x for x in data['club_dues'] if x['club_serial']==a)['due_serial'];dueB=next(x for x in data['club_dues'] if x['club_serial']==b)['due_serial'];seasonB=next(x for x in data['club_seasons'] if x['club_serial']==b)['season_serial']
login('admina2')
body=expect(workspace(a),200,'Assigned club administrator can open the club with global User role');check(b'membera@example.test' in body and b'shared@example.test' in body and b'memberb@example.test' not in body,'Club administrator sees all shared-club members and no unrelated tenant members')
home=app.request('/main.php?Xaction=Roster.workspace')[1];check(b'Tenant Beta' not in home and b'setup-users' not in home and b'view-journal' not in home,'Club administrator navigation hides other tenants and global administration')
for action,fields in [
('Users.setupUsers',{}),('Users.userSummary',{'user_serial':ids['memberb']}),('Users.editUser',{'step':'prompt','user_serial':ids['membera']}),('Users.editUser',{'step':'update','user_serial':ids['memberb'],'user_role':'Super System Administrator'}),
('Users.addUser',{'step':'add','user_email':'probe@example.test'}),('Users.deleteUser',{'user_serial':ids['memberb']}),('Users.resetPassword',{'user_serial':ids['memberb']}),('Users.updateActiveStatus',{'user_serial':ids['memberb'],'user_active':'false'}),('Users.userExists',{'user_name':'tenantmemberb'}),('Users.getUser_AJAX',{'user_name':'tenantmemberb'}),('Users.userActive',{'user_serial':ids['memberb']}),
('Notes.addNote',{'step':'add','note_type':'User','note_reference':ids['memberb'],'note_text':'Foreign note'}),('Journal.viewJournal',{}),('Alerts.setupAlerts',{}),('Popovers.setupPopovers',{}),('Statuses.setupStatuses',{}),('Dropdowns.setupDropdowntypes',{})]:
expect(app.post(action,**fields),403,'Club administrator cannot use global route '+action)
expect(workspace(b),403,'Changing the URL cannot open an unrelated tenant')
before=app.fixture('fingerprints')
for step,fields in [('club',{'club_name':'Stolen tenant'}),('member',{'membership_serial':ids['clubB_memberb'],'membership_status':'inactive','joined_on':'2026-01-01'}),('season',{'season_name':'Foreign season','starts_on':'2026-01-01','ends_on':'2026-12-31'}),('charge',{'membership_serial':ids['clubB_memberb'],'season_serial':seasonB,'amount':'10.00'}),('payment',{'due_serial':dueB,'amount':'10.00','paid_on':'2026-01-01','request_key':secrets.token_hex(32)}),('adjust',{'due_serial':dueB,'corrected_amount':'90.00','reason':'Foreign','request_key':secrets.token_hex(32)}),('invite',{'invite_email':'foreign@example.test'})]:
expect(app.post('Roster.workspace',step=step,club_serial=b,**fields),403,'Foreign tenant rejects '+step+' mutation')
check(app.fixture('fingerprints')==before,'Rejected cross-tenant writes leave all stored tables unchanged')
for export in ['csv','excel','pdf','ledger']:expect(app.post('RosterReports.'+export,club_serial=b),403,'Foreign tenant denies '+export+' export')
report=expect(app.post('Reports.selectReport'),200,'Club administrator can open scoped reports');check(b'Tenant Alpha' in report and b'Tenant Beta' not in report,'Report catalog includes only managed clubs')
csv=expect(app.post('RosterReports.csv',club_serial=a),200,'Club administrator can export the assigned club');check(b'membera@example.test' in csv and b'memberb@example.test' not in csv,'Roster export contains only the selected tenant')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueB,amount='10.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),404,'A foreign dues ID cannot be paired with an allowed club')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueA,amount='10.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),200,'Club administrator can record payment for an assigned-club member')
expect(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='invitee@example.test',membership_role='administrator'),403,'Club administrator cannot forge an administrator invitation')
users_before=app.fixture('fingerprints')['users'];invite_body=expect(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='invitee@example.test'),200,'Club administrator can invite a member by email');check(app.fixture('fingerprints')['users']==users_before,'Inviting an existing account does not change its login, email, role or password');check(b'could not be sent' in invite_body,'Invitation delivery failure remains visible in development')
invitation=app.fixture('inspect')['club_invitations'][0]['invitation_serial']
expect(app.post('Roster.workspace',step='member',club_serial=a,membership_serial=ids['clubA_shared'],membership_status='inactive',joined_on='2026-01-01'),200,'Club administrator can deactivate a membership in their club')
changed=app.fixture('inspect');check(next(x for x in changed['club_memberships'] if x['membership_serial']==ids['clubB_shared'])['membership_status']=='active' and next(x for x in changed['users'] if x['user_serial']==ids['shared'])['user_status']=='active','Membership deactivation leaves the shared account and other club membership active')
expect(app.post('Roster.workspace',step='member',club_serial=a,membership_serial=ids['clubA_shared'],membership_status='active',joined_on='2026-01-01'),200,'Club administrator can reactivate their own club membership')
login('admina');check(b'invitee@example.test' in workspace(a)[1],'Another administrator of the shared club sees its invitation')
own=expect(workspace(b),200,'Administrator of Alpha can read Beta as an ordinary Beta member');check(b'memberb@example.test' not in own and b'Record received payment' not in own,'Administrator in one club has member-only visibility in another club')
expect(app.post('RosterReports.csv',club_serial=b),403,'Being an ordinary member does not permit exporting that club')
login('membera');expect(app.post('Roster.invitations',step='accept',invitation_serial=invitation),404,'Another signed-in member cannot steal an invitation')
login('invitee');pending=expect(app.request('/main.php?Xaction=Roster.invitations'),200,'Invited member can open pending invitations');check(b'Tenant Alpha' in pending,'Correct invited email sees the club offer');expect(app.post('Roster.invitations',step='accept',invitation_serial=invitation),200,'Verified invited member accepts through the HTTP handler')
login('membera');member=expect(workspace(a),200,'Member can read assigned club and own dues');check(b'My season dues' in member and b'$90.00' in member and b'admina2@example.test' not in member and b'shared@example.test' not in member,'Member sees personal membership/dues rather than other member records')
expect(app.post('Roster.workspace',step='payment',club_serial=a,due_serial=dueA,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32)),403,'Members cannot mutate financial records')
expect(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='My dues question',message='<script>alert(1)</script> Please explain my dues.'),200,'Member can submit a club ticket')
ticket=app.fixture('inspect')['club_tickets'][0]['ticket_serial'];page=expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Member can read their submitted ticket');check(b'&lt;script&gt;' in page and b'<script>alert(1)</script>' not in page,'Ticket content is escaped when displayed')
missing=app.request(data={'action':'Roster.tickets','step':'reply','club_serial':a,'ticket_serial':ticket,'message':'No token'},ajax=True);expect(missing,403,'Ticket replies require CSRF protection')
login('shared');expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),404,'Another member in the same club cannot read a private ticket')
login('adminb');expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),403,'Other tenant administrator cannot read a ticket')
login('admina2');expect(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=ticket,message='Your payment was received.',ticket_status='closed'),200,'Assigned club administrator can answer and close the ticket')
login('membera');page=expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Member can read the administrator response');check(b'Your payment was received.' in page and b'Status: closed' in page,'Ticket reply and closed status are visible to its author')
app.logout();app.login();all_data=expect(workspace(b),200,'Super System Administrator can open any tenant');check(b'memberb@example.test' in all_data,'Super System Administrator can see all associated member data')
expect(app.request(f'/main.php?Xaction=Roster.tickets&club_serial={a}&ticket_serial={ticket}'),200,'Super System Administrator can inspect tickets across tenants')
# Removing a club assignment takes effect in existing sessions on the next request.
login('admina2');app.fixture('demoteTenantAdmin',str(ids['clubA_admina2']))
expect(app.post('Roster.workspace',step='season',club_serial=a,season_name='Revoked privilege',starts_on='2026-01-01',ends_on='2026-12-31'),403,'Club administrator demotion immediately removes management access without a new login')
finally:app.close()
print(str(len(checks))+' tenant HTTP checks passed.')