#!/usr/bin/env python3 """ audit.py — exhaustive Playwright audit for sport.rinet.one Author: cc1@rinet.one Date: 2026-05-05 Tab-by-tab traversal across 3 demo accounts. Captures screenshots, console errors, page errors, request failures (>=400 except 401/403), empty pages, and visible "error/exception" labels >5 occurrences. Outputs (in $AUDIT_DIR): shots/__.png — full-page screenshot per visited URL errors.json — structured findings ERROR_REPORT.md — markdown grouped by category run.log — verbose trace """ from __future__ import annotations import os, sys, json, time, re, traceback from datetime import datetime, timezone from collections import defaultdict from playwright.sync_api import sync_playwright BASE = 'https://sport.rinet.one' AUDIT_DIR = os.environ.get('AUDIT_DIR') or sys.exit("set AUDIT_DIR") SHOTS = os.path.join(AUDIT_DIR, 'shots') LOG_PATH = os.path.join(AUDIT_DIR, 'run.log') os.makedirs(SHOTS, exist_ok=True) ACCOUNTS = [ {'role':'pgz_admin', 'email':'damir@pgz.hr', 'password':'PGZ2026!'}, {'role':'savez_admin', 'email':'tajnik@atletski.pgz.hr', 'password':'Atl2026!'}, {'role':'klub_admin', 'email':'admin@ak-kvarner.hr', 'password':'Kvarner2026!'}, ] # Public (anonymous) URLs PUBLIC_PAGES = [ ('public/home', '/'), ('public/sport2', '/static/sport2.html'), ('public/login', '/static/login.html'), ('public/app', '/static/app.html'), ('public/admin', '/static/admin.html'), ('public/admin_users', '/static/admin_users.html'), ('public/audit', '/static/audit.html'), ('public/crm', '/static/crm.html'), ('public/erp', '/static/erp.html'), ('public/kpi', '/static/kpi.html'), ] # Authenticated sections (per role) — uses fragment / hash navigation ROLE_SECTIONS = { 'PORTAL': [ ('portal_dashboard', '/static/sport2.html#dashboard'), ('portal_savezi', '/static/sport2.html#savezi'), ('portal_klubovi', '/static/sport2.html#klubovi'), ('portal_sportasi', '/static/sport2.html#sportasi'), ('portal_manifestacije', '/static/sport2.html#manifestacije'), ('portal_objekti', '/static/sport2.html#objekti'), ], 'OPERATIVA': [ ('app_profil', '/static/app.html#profil'), ('app_kalendar','/static/app.html#kalendar'), ('app_notif', '/static/app.html#notifikacije'), ], 'CRM': [ ('crm_clanarine', '/static/crm.html#clanarine'), ('crm_lijecnicki', '/static/crm.html#lijecnicki'), ('crm_obrasci', '/static/crm.html#obrasci'), ('crm_dokumenti', '/static/crm.html#dokumenti'), ], 'ERP': [ ('erp_racuni', '/static/erp.html#racuni'), ('erp_putni', '/static/erp.html#putni'), ('erp_placanja', '/static/erp.html#placanja'), ('erp_xlsx', '/static/erp.html#xlsx'), ], 'ANALITIKA': [ ('an_kpi', '/static/kpi.html'), ('an_financije', '/static/sport2.html#financije'), ('an_mreza', '/static/sport2.html#mreza'), ('an_forenzika', '/static/sport2.html#forenzika'), ('an_audit', '/static/audit.html'), ], 'ADMIN': [ # only pgz_admin ('adm_korisnici', '/static/admin_users.html'), ('adm_tenanti', '/static/admin.html#tenants'), ('adm_sigurnost', '/static/admin.html#security'), ('adm_sustav', '/static/admin.html#system'), ], } CATEGORIES = ('console_error', 'page_error', 'request_failed', 'http_4xx_5xx', 'empty_page', 'visible_errors') def open_log(): return open(LOG_PATH, 'a', buffering=1) def slugify(s): return re.sub(r'[^a-zA-Z0-9._-]+', '_', s)[:80] def visit(page, key, url, role, errors, log): """Navigate, capture errors, screenshot. Returns dict of stats per page.""" page_errs = [] requests_failed = [] http_bad = [] consoles = [] def on_console(msg): if msg.type in ('error', 'warning'): consoles.append({'type': msg.type, 'text': msg.text[:500]}) def on_pageerror(exc): page_errs.append(str(exc)[:600]) def on_requestfailed(req): # Skip 401/403 (auth) noise unless we're logged in f = (req.failure or '') requests_failed.append({'url': req.url[:300], 'failure': f[:200], 'method': req.method}) def on_response(resp): try: s = resp.status if s >= 400 and s not in (401, 403): http_bad.append({'url': resp.url[:300], 'status': s}) except: pass page.on('console', on_console) page.on('pageerror', on_pageerror) page.on('requestfailed', on_requestfailed) page.on('response', on_response) log.write(f'\n[{role}/{key}] GOTO {url}\n') full_url = url if url.startswith('http') else BASE + url nav_err = None try: page.goto(full_url, wait_until='networkidle', timeout=20000) except Exception as e: nav_err = str(e)[:300] log.write(f' NAV ERR: {nav_err}\n') page.wait_for_timeout(2500) # Empty page detect body_len = 0 try: body_len = page.evaluate("() => document.body && (document.body.innerText||'').trim().length") except: pass empty = (body_len < 50) # Visible errors detect visible_err_count = 0 try: text = page.evaluate("() => (document.body && document.body.innerText || '').toLowerCase()") visible_err_count = sum(text.count(t) for t in ['error', 'failed', 'exception']) except: pass # Screenshot shot = os.path.join(SHOTS, f'{slugify(role)}__{slugify(key)}.png') try: page.screenshot(path=shot, full_page=True, timeout=10000) except Exception as e: log.write(f' SHOT ERR: {e}\n') shot = None # Detach listeners (prevent leakage to next visit) page.remove_listener('console', on_console) page.remove_listener('pageerror', on_pageerror) page.remove_listener('requestfailed', on_requestfailed) page.remove_listener('response', on_response) # Record errors base = {'role': role, 'page_key': key, 'url': full_url, 'screenshot': shot, 'body_len': body_len, 'visible_err_count': visible_err_count} for c in consoles: errors.append({**base, 'category':'console_error' if c['type']=='error' else 'console_warning', 'detail': c['text']}) for e in page_errs: errors.append({**base, 'category':'page_error', 'detail': e}) for r in requests_failed: errors.append({**base, 'category':'request_failed', 'detail': f"{r['method']} {r['url']} — {r['failure']}"}) for h in http_bad: errors.append({**base, 'category':'http_4xx_5xx', 'detail': f"HTTP {h['status']} {h['url']}"}) if empty: errors.append({**base, 'category':'empty_page', 'detail': f'body innerText only {body_len} chars'}) if visible_err_count > 5: errors.append({**base, 'category':'visible_errors', 'detail': f'{visible_err_count} occurrences of error/failed/exception in body'}) if nav_err: errors.append({**base, 'category':'page_error', 'detail': 'NAV: '+nav_err}) log.write(f' body_len={body_len} viserr={visible_err_count} ' f'console={len(consoles)} pageerr={len(page_errs)} ' f'reqfail={len(requests_failed)} http_bad={len(http_bad)}\n') def login(page, email, password, log): """Try the platform login flow. Returns True on success.""" log.write(f' LOGIN attempt {email}\n') try: page.goto(BASE + '/static/login.html', wait_until='networkidle', timeout=15000) page.wait_for_timeout(800) # Try common selectors for sel_e in ['input[type=email]', 'input[name=email]', '#email', 'input[placeholder*=mail i]']: if page.locator(sel_e).count() > 0: page.locator(sel_e).first.fill(email) break for sel_p in ['input[type=password]', 'input[name=password]', '#password']: if page.locator(sel_p).count() > 0: page.locator(sel_p).first.fill(password) break for sel_b in ['button[type=submit]', 'button:has-text("Prijava")', 'button:has-text("Login")', 'form button']: if page.locator(sel_b).count() > 0: try: page.locator(sel_b).first.click() break except: pass page.wait_for_timeout(3000) url_after = page.url ok = ('login' not in url_after.lower()) or ('logout' in (page.content() or '').lower()[:5000]) log.write(f' after login url={url_after} ok={ok}\n') return ok except Exception as e: log.write(f' LOGIN FAIL: {e}\n') return False def run(): log = open_log() log.write(f'=== audit start {datetime.now(timezone.utc).isoformat()} ===\n') errors = [] pages_visited = 0 with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=['--no-sandbox','--disable-setuid-sandbox']) # 1. PUBLIC pass — single context, no auth ctx = browser.new_context(viewport={'width':1280,'height':900}, ignore_https_errors=True) page = ctx.new_page() for key, url in PUBLIC_PAGES: visit(page, key, url, 'anon', errors, log) pages_visited += 1 ctx.close() # 2. PER-ACCOUNT passes for acc in ACCOUNTS: log.write(f'\n=== ROLE {acc["role"]} ({acc["email"]}) ===\n') ctx = browser.new_context(viewport={'width':1280,'height':900}, ignore_https_errors=True) page = ctx.new_page() ok = login(page, acc['email'], acc['password'], log) if not ok: errors.append({'role': acc['role'], 'page_key':'login', 'url': BASE+'/static/login.html', 'category':'page_error', 'detail': f'login failed for {acc["email"]} — auth not established'}) sections = list(ROLE_SECTIONS.items()) if acc['role'] != 'pgz_admin': sections = [(k,v) for k,v in sections if k != 'ADMIN'] for cat_name, items in sections: for key, url in items: visit(page, f'{cat_name}/{key}', url, acc['role'], errors, log) pages_visited += 1 ctx.close() browser.close() # Aggregate by_cat = defaultdict(int) by_page = defaultdict(int) for e in errors: by_cat[e['category']] += 1 by_page[e['role']+'/'+e['page_key']] += 1 top_pages = sorted(by_page.items(), key=lambda x:-x[1])[:20] out = { 'timestamp': datetime.now(timezone.utc).isoformat(), 'audit_dir': AUDIT_DIR, 'total_pages': pages_visited, 'total_errors': len(errors), 'errors_by_category': dict(by_cat), 'top_pages': [{'page':k,'count':v} for k,v in top_pages], 'errors': errors, 'screenshots': sorted(os.listdir(SHOTS)), } json_path = os.path.join(AUDIT_DIR, 'errors.json') with open(json_path, 'w') as f: json.dump(out, f, indent=2, default=str, ensure_ascii=False) log.write(f'\n=== wrote {json_path} ===\n') log.write(f' pages={pages_visited} errors={len(errors)} by_cat={dict(by_cat)}\n') log.close() # Markdown report md = [] md.append(f'# Playwright Audit Report\n') md.append(f'**Generated:** {out["timestamp"]}\n') md.append(f'**Audit dir:** `{AUDIT_DIR}`\n') md.append(f'**Pages visited:** {pages_visited}\n') md.append(f'**Total errors:** {len(errors)}\n\n') md.append('## Errors by category\n\n| Category | Count |\n|---|---:|\n') for k in sorted(by_cat, key=lambda c:-by_cat[c]): md.append(f'| {k} | {by_cat[k]} |\n') md.append('\n## Top 20 pages by error count\n\n| Page | Errors |\n|---|---:|\n') for k,v in top_pages: md.append(f'| `{k}` | {v} |\n') md.append('\n## Errors grouped by category\n') for cat in sorted(by_cat, key=lambda c:-by_cat[c]): md.append(f'\n### {cat} ({by_cat[cat]})\n\n') items = [e for e in errors if e['category']==cat] # group by page by_p = defaultdict(list) for e in items: by_p[e['role']+'/'+e['page_key']].append(e) for p, es in sorted(by_p.items(), key=lambda x:-len(x[1])): md.append(f'\n**`{p}`** ({len(es)})\n\n') for e in es[:10]: detail = (e.get('detail','') or '')[:300] md.append(f'- {detail}\n') if len(es) > 10: md.append(f'- _… and {len(es)-10} more_\n') md.append('\n## Per-role page status (full grid)\n\n| Role | Page | Body chars | Visible errs | Total findings |\n|---|---|---:|---:|---:|\n') grid = defaultdict(lambda: {'body':None,'vis':None,'cnt':0}) for e in errors: k = (e['role'], e['page_key']) grid[k]['cnt'] += 1 grid[k]['body'] = e.get('body_len') grid[k]['vis'] = e.get('visible_err_count') for (role, pkey), v in sorted(grid.items()): md.append(f'| {role} | `{pkey}` | {v["body"]} | {v["vis"]} | {v["cnt"]} |\n') md_path = os.path.join(AUDIT_DIR, 'ERROR_REPORT.md') with open(md_path, 'w') as f: f.write(''.join(md)) print(f'wrote {md_path} ({len("".join(md))} chars)') print(f'wrote {json_path} (errors={len(errors)} pages={pages_visited})') if __name__ == '__main__': run()