Files
pgz-sport/_audit/audit.py
T
CC1 662f448590 CC1: Playwright audit 20260505_023639 — 57 errors across 80 pages
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>
2026-05-05 08:05:18 +02:00

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()