Files
JQC_multi_tenant/tests/test_ui_theme.py
T
2026-08-19 16:26:32 -04:00

261 lines
10 KiB
Python

"""
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 'Customer Inspector' in body