feat: add page shell, preferences and translations
Base layout with top bar, role-based menu, profile menu and phone layout; theme, language and German form of address kept in cookies; German formal, German informal and English text; Fluent avatar bundled in the image by a Node build stage; placeholder pages for the Publisher and Admin groups. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polib
|
||||
import pytest
|
||||
|
||||
from portal.adapters import registry
|
||||
@@ -36,3 +38,19 @@ def adapters():
|
||||
registry.install(fakes.directory, fakes.idp)
|
||||
yield fakes
|
||||
registry.install()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def compiled_messages():
|
||||
"""The image compiles the catalogues with gettext; tests compile their own copy."""
|
||||
for po_file in (Path(__file__).resolve().parent.parent / "locale").glob("*/LC_MESSAGES/*.po"):
|
||||
polib.pofile(str(po_file)).save_as_mofile(str(po_file.with_suffix(".mo")))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def plain_static_files(settings):
|
||||
"""No collectstatic in tests, so use storage without a manifest."""
|
||||
settings.STORAGES = {
|
||||
**settings.STORAGES,
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Per-device preferences: cookies in, validated values out (AD-15)."""
|
||||
|
||||
import pytest
|
||||
from django.test import Client, RequestFactory
|
||||
|
||||
from portal.shell import preferences
|
||||
from portal.shell.preferences import Preferences
|
||||
|
||||
|
||||
def request_with(cookies=None, accept_language=""):
|
||||
request = RequestFactory().get("/", HTTP_ACCEPT_LANGUAGE=accept_language)
|
||||
request.COOKIES.update(cookies or {})
|
||||
return request
|
||||
|
||||
|
||||
def test_without_cookies_the_defaults_apply():
|
||||
prefs = preferences.from_request(request_with())
|
||||
assert prefs == Preferences(theme="system", language="en", form="sie")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("header", "expected"),
|
||||
[("de-DE,de;q=0.9,en;q=0.5", "de"), ("en-GB", "en"), ("fr-FR", "en"), ("", "en")],
|
||||
)
|
||||
def test_the_language_defaults_from_the_browser(header, expected):
|
||||
assert preferences.from_request(request_with(accept_language=header)).language == expected
|
||||
|
||||
|
||||
def test_valid_cookies_are_used_and_beat_the_browser():
|
||||
cookies = {"pm_theme": "dark", "pm_lang": "en", "pm_form": "du"}
|
||||
prefs = preferences.from_request(request_with(cookies, accept_language="de"))
|
||||
assert prefs == Preferences(theme="dark", language="en", form="du")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "blue", "<script>", "DARK", "x" * 500])
|
||||
def test_forged_cookie_values_fall_back_to_the_defaults(value):
|
||||
cookies = {"pm_theme": value, "pm_lang": value, "pm_form": value}
|
||||
prefs = preferences.from_request(request_with(cookies))
|
||||
assert prefs == Preferences(theme="system", language="en", form="sie")
|
||||
|
||||
|
||||
def test_the_informal_form_only_changes_german():
|
||||
assert Preferences(language="de", form="du").language_code == "de-du"
|
||||
assert Preferences(language="de", form="sie").language_code == "de"
|
||||
assert Preferences(language="en", form="du").language_code == "en"
|
||||
|
||||
|
||||
def test_only_valid_choices_are_taken_from_a_submission():
|
||||
assert preferences.chosen({"theme": "dark", "language": "xx", "form": "du"}) == {
|
||||
"pm_theme": "dark",
|
||||
"pm_form": "du",
|
||||
}
|
||||
assert preferences.chosen({}) == {}
|
||||
|
||||
|
||||
# -- the preferences address ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_choosing_sets_the_cookies_and_returns_to_the_page():
|
||||
response = Client().post(
|
||||
"/preferences", {"theme": "dark", "language": "de", "form": "du", "next": "/x?y=1"}
|
||||
)
|
||||
assert response.status_code == 302 and response["Location"] == "/x?y=1"
|
||||
for name, value in (("pm_theme", "dark"), ("pm_lang", "de"), ("pm_form", "du")):
|
||||
cookie = response.cookies[name]
|
||||
assert cookie.value == value
|
||||
assert cookie["samesite"] == "Lax" and cookie["secure"] and not cookie["httponly"]
|
||||
assert cookie["max-age"] == preferences.COOKIE_MAX_AGE
|
||||
|
||||
|
||||
def test_an_invalid_value_sets_no_cookie():
|
||||
response = Client().post("/preferences", {"theme": "neon", "language": "fr"})
|
||||
assert not response.cookies
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["https://evil.example.test/", "//evil.example.test", "x", ""])
|
||||
def test_the_return_address_cannot_leave_the_site(target):
|
||||
response = Client().post("/preferences", {"theme": "dark", "next": target})
|
||||
assert response["Location"] == "/"
|
||||
|
||||
|
||||
def test_choosing_needs_post():
|
||||
assert Client().get("/preferences").status_code == 405
|
||||
|
||||
|
||||
def test_choosing_needs_a_csrf_token():
|
||||
strict = Client(enforce_csrf_checks=True)
|
||||
assert strict.post("/preferences", {"theme": "dark"}).status_code == 403
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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.test_signin 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"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Every user-visible string has German text (AD-15); the informal catalogue only differs."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import polib
|
||||
import pytest
|
||||
|
||||
PORTAL = Path(__file__).resolve().parent.parent
|
||||
LOCALE = PORTAL / "locale"
|
||||
|
||||
TRANS_TAG = re.compile(r"""\{%\s*trans\s+(?:"([^"]+)"|'([^']+)')""")
|
||||
BLOCKTRANS = re.compile(r"\{%\s*blocktrans[^%]*%\}(.*?)\{%\s*endblocktrans\s*%\}", re.S)
|
||||
VARIABLE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
PY_CALL = re.compile(r"""(?<![\w.])(?:_|gettext|gettext_lazy)\(\s*(?:"([^"]+)"|'([^']+)')""")
|
||||
|
||||
|
||||
def msgids_in_code() -> set[str]:
|
||||
found = set()
|
||||
for template in (PORTAL / "templates").rglob("*.html"):
|
||||
text = template.read_text(encoding="utf-8")
|
||||
found.update(a or b for a, b in TRANS_TAG.findall(text))
|
||||
found.update(VARIABLE.sub(r"%(\1)s", body).strip() for body in BLOCKTRANS.findall(text))
|
||||
for source in PORTAL.rglob("*.py"):
|
||||
if "tests" in source.parts or "migrations" in source.parts:
|
||||
continue
|
||||
found.update(a or b for a, b in PY_CALL.findall(source.read_text(encoding="utf-8")))
|
||||
return found
|
||||
|
||||
|
||||
def catalogue(name: str) -> polib.POFile:
|
||||
return polib.pofile(str(LOCALE / name / "LC_MESSAGES" / "django.po"))
|
||||
|
||||
|
||||
def test_the_scan_finds_the_strings():
|
||||
assert {"Sign out", "Home", "Hello, %(name)s", "Audit log"} <= msgids_in_code()
|
||||
|
||||
|
||||
def test_every_string_in_the_code_has_german_text():
|
||||
german = {e.msgid: e for e in catalogue("de")}
|
||||
missing = sorted(msgids_in_code() - german.keys())
|
||||
assert not missing, f"no German entry for: {missing}"
|
||||
for entry in german.values():
|
||||
assert entry.msgstr.strip(), f"empty German text for {entry.msgid!r}"
|
||||
assert "fuzzy" not in entry.flags, f"fuzzy entry {entry.msgid!r}"
|
||||
|
||||
|
||||
def test_the_german_catalogue_has_no_stale_entries():
|
||||
stale = sorted({e.msgid for e in catalogue("de")} - msgids_in_code())
|
||||
assert not stale, f"entries no longer used: {stale}"
|
||||
|
||||
|
||||
def test_the_informal_catalogue_only_holds_differences_to_known_strings():
|
||||
german = {e.msgid: e.msgstr for e in catalogue("de")}
|
||||
for entry in catalogue("de_DU"):
|
||||
assert entry.msgid in german, f"unknown string {entry.msgid!r}"
|
||||
assert entry.msgstr.strip() and "fuzzy" not in entry.flags
|
||||
assert entry.msgstr != german[entry.msgid], f"{entry.msgid!r} does not differ"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["de", "de_DU"])
|
||||
def test_placeholders_survive_translation(name):
|
||||
for entry in catalogue(name):
|
||||
assert set(re.findall(r"%\(\w+\)s", entry.msgid)) == set(
|
||||
re.findall(r"%\(\w+\)s", entry.msgstr)
|
||||
), entry.msgid
|
||||
|
||||
|
||||
def test_the_formal_text_does_not_use_du_and_the_informal_does_not_use_sie():
|
||||
formal = " ".join(e.msgstr for e in catalogue("de"))
|
||||
assert not re.search(r"\b(du|dir|dich|dein|deine)\b", formal, re.I)
|
||||
informal = " ".join(e.msgstr for e in catalogue("de_DU"))
|
||||
assert not re.search(r"\b(Sie|Ihnen|Ihr|Ihre)\b", informal)
|
||||
Reference in New Issue
Block a user