generated from jric11/baseProject
169 lines
17 KiB
Python
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'<script>text only</script>' 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()
|