Files
ping-mee-self-service-platform/portal/tests/test_shell.py
T
ebbe.bassandClaude Sonnet 5.5 700295cb72 refactor: sweep the platform-and-sign-in epic
Split load_config into per-section readers, use contextlib.suppress and a single condition where ruff asked, remove stale noqa comments, add the SIM and RUF100 rules, share the sign_in test helper, align AD-6 with the code and record the remaining deferred findings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-18 09:29:59 +02:00

226 lines
8.8 KiB
Python

"""The page shell: layout, role-based menu, preferences applied to pages (AD-15, AD-16)."""
import re
from pathlib import Path
import pytest
from django.test import Client
from django.utils import translation
from portal.tests.helpers import sign_in
pytestmark = pytest.mark.django_db(transaction=True)
STATIC = Path(__file__).resolve().parent.parent / "static" / "portal"
@pytest.fixture
def people(adapters):
d = adapters.directory
d.add_user("alice", display_name="Alice Anders")
d.add_user("pub", display_name="Pat Publisher", is_publisher=True)
d.add_user("boss", display_name="Bo Boss", is_admin=True, is_publisher=True)
d.add_user("admin", display_name="Ann Admin", is_admin=True)
return d
def page(adapters, username="alice", path="/", **cookies):
client, _ = sign_in(adapters, username)
for name, value in cookies.items():
client.cookies[name] = value
return client.get(path), client
def menu_of(response):
html = response.content.decode()
nav = re.search(r'<nav id="menu".*?</nav>', html, re.S)
return nav.group(0) if nav else ""
# -- the layout ----------------------------------------------------------------------------------
def test_a_signed_in_page_has_the_shell_and_the_users_name(people, adapters):
response, _ = page(adapters)
html = response.content.decode()
assert response.status_code == 200
assert 'class="top-bar"' in html and '<nav id="menu"' in html and '<main id="content"' in html
assert html.count("Alice Anders") >= 2 # heading and profile menu
assert ">AA<" in html # initials until the avatar component is defined
def test_the_skip_link_is_the_first_thing_in_the_page(people, adapters):
html = page(adapters)[0].content.decode()
body = html[html.index("<body>") :]
assert body.index('class="skip-link"') < body.index("<header")
assert 'href="#content"' in html
def test_the_page_loads_its_scripts_and_styles_from_its_own_origin(people, adapters):
html = page(adapters)[0].content.decode()
sources = re.findall(r'(?:src|href)="([^"]+)"', html)
assert all(not s.startswith(("http:", "https:", "//")) for s in sources)
assert "/static/vendor/fluent.js" in sources and "/static/portal/shell.js" in sources
def test_nothing_is_inline_so_the_strict_policy_holds(people, adapters):
for username in ("alice", "boss"):
html = page(adapters, username)[0].content.decode()
assert not re.search(r"<script(?![^>]*\bsrc=)", html)
assert not re.search(r"<style\b", html)
assert not re.search(r"\sstyle=", html)
assert not re.search(r"\son[a-z]+=", html)
def test_the_policy_allows_scripts_only_from_the_site(people, adapters):
csp = page(adapters)[0]["Content-Security-Policy"]
assert "script-src 'self'" in csp and "unsafe-inline" not in csp and "unsafe-eval" not in csp
def test_the_current_page_is_marked_in_the_menu(people, adapters):
nav = menu_of(page(adapters)[0])
assert re.search(r'<a href="/" class="current" aria-current="page">', nav)
def test_signed_out_pages_use_the_layout_without_menu_or_avatar():
html = Client().get("/auth/signed-out").content.decode()
assert 'class="top-bar"' in html and "ping-mee Portal" in html
assert '<nav id="menu"' not in html and 'class="profile"' not in html
def test_the_wide_and_phone_layouts_are_in_the_stylesheet():
css = (STATIC / "shell.css").read_text(encoding="utf-8")
assert "@media (width <= 720px)" in css
assert "--touch: 44px" in css and "--nav-width: 220px" in css
narrow = css[css.index("@media (width <= 720px)") :]
assert ".js #menu" in narrow and "display: none" in narrow
assert "flex-direction: column" in narrow
# -- the menu by role -----------------------------------------------------------------------------
def test_a_normal_user_sees_only_the_home_entry(people, adapters):
nav = menu_of(page(adapters, "alice")[0])
assert "Home" in nav and "Publishing" not in nav and "Administration" not in nav
def test_a_publisher_sees_publishing_but_not_administration(people, adapters):
nav = menu_of(page(adapters, "pub")[0])
assert "Publishing" in nav and "Manage announcements" in nav
assert "Administration" not in nav
def test_an_admin_sees_administration_but_not_publishing(people, adapters):
nav = menu_of(page(adapters, "admin")[0])
assert "Administration" in nav and "Audit log" in nav
assert "Publishing" not in nav
def test_someone_who_is_both_sees_both_groups(people, adapters):
nav = menu_of(page(adapters, "boss")[0])
assert "Publishing" in nav and "Administration" in nav
PAGES = {
"/publish/announcements": "publisher",
"/administration/appearance": "admin",
"/administration/settings": "admin",
"/administration/audit": "admin",
"/administration/status": "admin",
}
@pytest.mark.parametrize("path", PAGES)
def test_hidden_pages_refuse_a_direct_request(people, adapters, path):
allowed = {"publisher": {"pub", "boss"}, "admin": {"admin", "boss"}}[PAGES[path]]
for username in ("alice", "pub", "admin", "boss"):
response, _ = page(adapters, username, path)
assert response.status_code == (200 if username in allowed else 403), username
def test_a_refused_page_says_so_in_the_shell(people, adapters):
response, _ = page(adapters, "alice", "/administration/audit")
html = response.content.decode()
assert response.status_code == 403 and "do not have permission" in html
assert 'class="top-bar"' in html and "Audit log" not in menu_of(response)
def test_pages_for_roles_need_a_sign_in():
response = Client().get("/administration/audit")
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
# -- preferences applied to the page ----------------------------------------------------
def test_the_theme_is_an_attribute_on_the_root_element(people, adapters):
assert 'data-theme="system"' in page(adapters)[0].content.decode()
assert 'data-theme="dark"' in page(adapters, pm_theme="dark")[0].content.decode()
assert 'data-theme="light"' in page(adapters, pm_theme="light")[0].content.decode()
def test_an_unknown_theme_cookie_is_not_echoed(people, adapters):
html = page(adapters, pm_theme='"><script>x</script>')[0].content.decode()
assert "<script>x" not in html and 'data-theme="system"' in html
def test_the_browser_language_is_used_when_nothing_is_chosen(people, adapters):
client, _ = sign_in(adapters, "alice")
html = client.get("/", HTTP_ACCEPT_LANGUAGE="de-DE,de;q=0.9").content.decode()
assert '<html lang="de"' in html and "Guten Tag, Alice Anders" in html
def test_german_formal_is_the_default_german(people, adapters):
html = page(adapters, pm_lang="de")[0].content.decode()
assert "Guten Tag, Alice Anders" in html and "Sie sind angemeldet" in html
assert "Startseite" in menu_of(page(adapters, pm_lang="de")[0])
def test_german_informal_uses_du(people, adapters):
html = page(adapters, pm_lang="de", pm_form="du")[0].content.decode()
assert "Hallo, Alice Anders" in html and "Du bist angemeldet" in html
def test_the_form_of_address_does_not_change_english(people, adapters):
html = page(adapters, pm_lang="en", pm_form="du")[0].content.decode()
assert "Hello, Alice Anders" in html and "Hallo" not in html
def test_the_form_control_is_shown_only_in_german(people, adapters):
assert 'value="du"' in page(adapters, pm_lang="de")[0].content.decode()
assert 'value="du"' not in page(adapters, pm_lang="en")[0].content.decode()
def test_the_chosen_options_are_marked_pressed(people, adapters):
html = page(adapters, pm_theme="dark")[0].content.decode()
assert re.search(r'value="dark"[^>]*aria-pressed="true"', html)
assert re.search(r'value="system"[^>]*aria-pressed="false"', html)
def test_the_profile_menu_holds_sign_out_and_posts_back_to_the_page(people, adapters):
response, _ = page(adapters, path="/")
html = response.content.decode()
assert 'action="/auth/logout"' in html and 'action="/preferences"' in html
assert 'name="next" value="/"' in html
def test_responses_are_marked_as_varying_with_the_preferences(people, adapters):
response, _ = page(adapters, pm_lang="de")
assert response["Content-Language"] == "de"
assert {"Cookie", "Accept-Language"} <= {v.strip() for v in response["Vary"].split(",")}
def test_the_language_does_not_leak_into_the_next_request(people, adapters):
client, _ = sign_in(adapters, "alice")
client.cookies["pm_lang"] = "de"
assert "Startseite" in menu_of(client.get("/"))
del client.cookies["pm_lang"]
assert "Home" in menu_of(client.get("/"))
def test_a_german_string_with_no_informal_entry_falls_back_to_the_formal_one():
with translation.override("de-du"):
assert translation.gettext("Home") == "Startseite"
assert translation.gettext("Sign out") == "Abmelden"