Aug 7 - Update: UI change - MT16
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user