""" tests/test_ui_theme.py ---------------------- Behaviour tests for MT-16 — the classic/modern web portal design. Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers: * every page still renders on the DEFAULT (classic) design — the layout split must be invisible to the 69 page templates that extend base.html * the same pages render on the modern design, exercising layouts/modern.html and every modern/ override (this is what catches a BuildError from an endpoint name that exists in ST but not MT) * the switch persists, is POST-only, validates its input, and guards against open redirects * ThemedEnvironment serves the modern override ONLY to a modern user — a cached modern template must never leak to a classic user * /api/ requests never get a rewritten template name * the vote tally is admin-only The render matrix is the point of this module. ST and MT diverge on blueprint names (scheduled_inspections vs inspection_schedules) and endpoint names (facility_qr_print_all vs qr_print_all), so a template copied from ST raises BuildError at RENDER time, not import time — only an actual GET catches it. """ import pytest @pytest.fixture def client(app): """Fresh schema + test client for each test (isolated in-memory DB).""" with app.app_context(): from app import db from app.models import inspector_assignment # noqa: F401 db.drop_all() db.create_all() yield app.test_client() db.session.remove() def _user(username, role, theme='classic'): from app import db from app.models.user import User u = User(username=username, full_name=username.title(), role=role, email=f'{username}@example.com', active=True, ui_theme=theme) u.set_password('pw-correct1') db.session.add(u) db.session.commit() return u def _login(client, user): resp = client.post('/auth/login', data={'username': user.username, 'password': 'pw-correct1'}, follow_redirects=True) # Guard: if login silently fails, every follow_redirects=True GET below # lands on the login page and returns 200, so a plain status assertion # would pass while rendering NOTHING under test. Fail loudly here instead. assert 'Login - ' not in resp.get_data(as_text=True), ( f'login failed for {user.username} — render assertions would be vacuous') return resp def _assert_real_page(resp, path): """A 200 is not enough: an unauthenticated GET follows the redirect to the login page and also returns 200. Assert we are not looking at it.""" assert resp.status_code == 200, f'{path} -> {resp.status_code}' body = resp.get_data(as_text=True) assert 'Login - ' not in body, f'{path} bounced to the login page' return body # Pages that exist on both designs. The modern run exercises the overrides. _PAGES = [ '/', '/inspections/', '/issues/', '/facilities/', '/reports/', '/ui/about', '/ui/support-center', ] # ── Column default ─────────────────────────────────────────────────────────── def test_new_user_defaults_to_classic(client): """MT deliberately does NOT adopt ST's phase50 'modern for everyone'.""" from app import db from app.models.user import User u = User(username='fresh', role='admin', email='fresh@example.com', active=True) u.set_password('pw-correct1') db.session.add(u) db.session.commit() assert u.ui_theme == 'classic' # ── Render matrix ──────────────────────────────────────────────────────────── @pytest.mark.parametrize('path', _PAGES) def test_pages_render_on_classic(client, path): admin = _user('ada', 'admin', theme='classic') _login(client, admin) _assert_real_page(client.get(path, follow_redirects=True), path) @pytest.mark.parametrize('path', _PAGES) def test_pages_render_on_modern(client, path): """Catches BuildError from any ST endpoint name that MT does not have.""" admin = _user('ada', 'admin', theme='modern') _login(client, admin) _assert_real_page(client.get(path, follow_redirects=True), path) def test_modern_dashboard_renders_for_every_role(client): """The dashboard route branches hard on role; the modern override reads variables from all of those branches.""" for i, role in enumerate(['admin', 'director', 'project_manager', 'auditor', 'inspector', 'external_inspector']): # LoginForm.username enforces Length(min=3), so the generated name must # clear it — a 2-char name silently re-renders the login page. user = _user(f'user{i}', role, theme='modern') _login(client, user) _assert_real_page(client.get('/', follow_redirects=True), f'/ as {role}') client.get('/auth/logout', follow_redirects=True) # ── Layout dispatch ────────────────────────────────────────────────────────── def test_classic_user_gets_classic_shell(client): admin = _user('ada', 'admin', theme='classic') _login(client, admin) body = client.get('/', follow_redirects=True).get_data(as_text=True) assert 'jqc-sidebar' not in body assert 'theme_modern.css' not in body def test_modern_user_gets_modern_shell(client): admin = _user('ada', 'admin', theme='modern') _login(client, admin) body = client.get('/', follow_redirects=True).get_data(as_text=True) assert 'jqc-sidebar' in body assert 'theme_modern.css' in body def test_modern_override_does_not_leak_to_classic_user(client): """Jinja caches templates by name. The rewrite happens in get_template() so the cache key is the REWRITTEN name — a modern template rendered for one user must never be served to a classic user afterwards. """ modern_user = _user('mod', 'admin', theme='modern') _login(client, modern_user) modern_body = client.get('/', follow_redirects=True).get_data(as_text=True) assert 'jqc-sidebar' in modern_body client.get('/auth/logout', follow_redirects=True) classic_user = _user('cla', 'admin', theme='classic') _login(client, classic_user) classic_body = client.get('/', follow_redirects=True).get_data(as_text=True) assert 'jqc-sidebar' not in classic_body, 'modern template leaked via cache' # ── The switch ─────────────────────────────────────────────────────────────── def test_switch_persists_the_choice(client): from app.models.user import User admin = _user('ada', 'admin', theme='classic') _login(client, admin) client.post('/ui/theme', data={'theme': 'modern'}, follow_redirects=True) assert User.query.filter_by(username='ada').first().ui_theme == 'modern' body = client.get('/', follow_redirects=True).get_data(as_text=True) assert 'jqc-sidebar' in body def test_switch_back_to_classic(client): from app.models.user import User admin = _user('ada', 'admin', theme='modern') _login(client, admin) client.post('/ui/theme', data={'theme': 'classic'}, follow_redirects=True) assert User.query.filter_by(username='ada').first().ui_theme == 'classic' def test_switch_rejects_unknown_theme(client): from app.models.user import User admin = _user('ada', 'admin', theme='classic') _login(client, admin) client.post('/ui/theme', data={'theme': 'neon'}, follow_redirects=True) assert User.query.filter_by(username='ada').first().ui_theme == 'classic' def test_switch_is_post_only(client): admin = _user('ada', 'admin', theme='classic') _login(client, admin) assert client.get('/ui/theme').status_code == 405 def test_switch_requires_login(client): resp = client.post('/ui/theme', data={'theme': 'modern'}) assert resp.status_code in (302, 401) def test_switch_refuses_offsite_next(client): """A protocol-relative URL is a valid redirect target to the browser but points off-site, so the leading-slash test alone is not enough.""" admin = _user('ada', 'admin', theme='classic') _login(client, admin) resp = client.post('/ui/theme', data={'theme': 'modern', 'next': '//evil.example.com/x'}) assert 'evil.example.com' not in resp.headers.get('Location', '') def test_switch_is_audit_logged(client): from app.models.audit import AuditLog admin = _user('ada', 'admin', theme='classic') _login(client, admin) client.post('/ui/theme', data={'theme': 'modern'}, follow_redirects=True) entries = AuditLog.query.filter_by(entity_type='User').all() assert any('ui_theme' in (e.details or '') for e in entries) # ── API traffic is never themed ────────────────────────────────────────────── def test_api_requests_are_not_themed(client): """resolve_ui_theme() short-circuits /api/ so it never touches the Flask-Login session loader on JWT traffic.""" resp = client.get('/api/v1/stats/dashboard') # Unauthenticated, so a 401/403/404 — the point is that it does not 500 # inside the theme resolver. assert resp.status_code < 500 # ── Vote tally ─────────────────────────────────────────────────────────────── def test_theme_votes_is_admin_only(client): director = _user('dan', 'director', theme='classic') _login(client, director) resp = client.get('/ui/theme-votes', follow_redirects=True) assert 'Design' not in resp.get_data(as_text=True) or resp.status_code == 200 # The route redirects non-admins to the dashboard rather than 403. assert client.get('/ui/theme-votes').status_code == 302 def test_theme_votes_renders_for_admin(client): _user('ivy', 'inspector', theme='modern') _user('xan', 'external_inspector', theme='classic') admin = _user('ada', 'admin', theme='classic') _login(client, admin) resp = client.get('/ui/theme-votes') assert resp.status_code == 200 body = resp.get_data(as_text=True) # MT-15's ROLE_LABELS must be used for the raw group_by role strings. assert 'External Inspector' in body