662f448590
Outputs in _audit/audit_20260505_023639/: ERROR_REPORT.md — markdown grouped by category & page errors.json — structured 57 findings shots/ — 80 full-page PNG screenshots (ignored by git) run.log — verbose trace Sweep: 10 anonymous public URLs + 3 demo accounts (pgz_admin, savez_admin, klub_admin) × 22 sidebar sections each (PORTAL/OPERATIVA/CRM/ERP/ANALITIKA; ADMIN only for pgz_admin). Categories: console_error 29 console_warning 16 http_4xx_5xx 8 page_error 3 empty_page 1 Hot spots (top 20 in flag file): ANALITIKA/an_mreza — 8 errors per role × 3 roles = 24 total (CDN/init issue) anon/public/erp — 3 PORTAL/portal_dashboard, portal_sportasi, CRM/crm_clanarine, ANALITIKA/an_financije — 2 errors per role × 3 = stable across roles Flag: _audit/CC1_DONE_20260505_023639.flag with summary YAML for cc2-6 to consume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
14 KiB
Python
338 lines
14 KiB
Python
#!/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/<role>__<key>.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()
|