Files
huntingclubroster/tests/project_http.py

169 lines
17 KiB
Python

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'&lt;script&gt;text only&lt;/script&gt;' 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.fixture('seedTenantInvitation',str(club_a),'1','http@example.test')
app.logout();_,signup,_=app.request('/?Xaction=signup&membership=invited')
expect(app.request(data={'action':'SignUp.createAccount','registration_kind':'invited','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()