Files
Anthias/tests/test_error_pages.py
Viktor Petersson 6877390194 feat(ui): modernize splash, login, and error pages (#2824)
* feat(ui): modernize splash, login, and error pages

Splash gets a gradient background with an elevated card, status pill
(pulsing while detecting, solid when ready), monospace IP pills, and
an SVG QR code generated client-side from the qrcode npm package via
a new bun-bundled splash.ts entry. Login mirrors the pre-auth gradient
treatment with a centred light card. Adds 400/403/404/500 templates
that share the same layout via _error.html so Django stops falling
back to its default plain-HTML error pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): use dark-text logo on light auth/error cards

logo-full.svg has the wordmark filled white (designed for the dark
navbar). On the white auth card it disappears. dark.svg is the same
mark with a black wordmark — the right asset for light backgrounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address Copilot feedback on splash/login/error pages

- Strip the global 4.5rem navbar offset on full-bleed pre-auth pages
  via a body_class block, so login + error cards aren't shifted down
  72 px on screens that don't render the navbar.
- Hide the splash CTA copy ("Open this on your phone…") until at
  least one IP arrives — on cold boot the page now stays on the
  pulsing "Detecting network…" chip instead of pointing operators at
  addresses that don't exist yet.
- Neutralize the 403 copy (IP allow-list / internal-auth rejections
  are not fixable by re-auth) and drop the misleading "Sign in" CTA
  in favour of "Back to dashboard".
- Reverse the dashboard CTA URL through {% url %} instead of a hard-
  coded "/".
- Add tests/test_error_pages.py covering all four templates via
  Django's default 4xx/5xx handlers, so a broken extends chain or
  missing static reference can't slip through to a real outage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): satisfy mypy + ruff format on test_error_pages.py

- Add type annotations on the rf fixture and parametrized callable
  view argument so mypy stops warning on the no-untyped-def rule.
- Hoist the import + run ruff format to drop the in-function import
  and tidy the layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): second Copilot pass on splash/login/error pages

- Splash drops the unused tailwind.css fetch (no Tailwind utility
  classes on this page) and adds a viewport meta tag so phones
  scanning the QR don't fall back to the 980 px layout viewport.
- _error.html no longer extends base.html: that was pulling vendor.js
  on every public 404 and opening a /ws connection on crawler/bot
  traffic. Standalone shell loads only tabler-icons + anthias.css.
- .splash-qr swaps `aspect-ratio: 1/1` for an explicit
  `width = height = clamp()` pair, since aspect-ratio is Chromium 88+
  and the Qt5 webview on Pi 1-4 is older — without the fallback the
  QR slot would collapse on those engines.
- views.login now honours `next` on POST through
  url_has_allowed_host_and_scheme so safe same-host destinations are
  preserved; off-host and scheme-mismatched values fall back to the
  dashboard so the login flow can't be turned into an open redirect.
- Add 403_csrf.html so Django's CSRF failure view (csrf_failure)
  brands the page instead of falling back to the stock plain-HTML.
- Convert multi-line {# #} comments to {% comment %} blocks where
  they were leaking into the rendered output.
- Add tests/test_login_view.py (5 cases: GET render, next round-trip,
  safe-next redirect, off-host rejection, error round-trip), bundle
  resolution check in test_splash_page.py, and 403_csrf in the
  parametrized error-template suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): split splash bundle test — finder check is integration-only

The unit-test job runs before bin/prepare_test_environment.sh, which
is what invokes \`bun run build\`. Asserting finders.find() resolves
dist/js/splash.js in the unit suite was therefore guaranteed to fail
in CI even though the test passes locally (where the bundle exists).

Split into two: the template-reference check stays in the unit suite
(catches a dropped <script> tag), and the build-artifact check moves
to @pytest.mark.integration so it runs after the build step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sonar): clear 7 PR-new findings, restore Quality Gate

- splash.ts: prefer globalThis over window (S7764) and split render()
  into showEmpty/showStatusReady/paintIpPills/paintQr to drop
  cognitive complexity from 20 to under the 15 threshold (S3776).
- splash-page.html inline script: globalThis.__splashIpsUrl (S7764).
- test_login_view.py: centralize the test password literal behind a
  module-level _FIXTURE_PASSWORD constant with a single NOSONAR
  suppression — same pattern test_splash_page.py uses for IP
  literals — instead of three S2068 hits (one per call site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): third Copilot pass — auth next, login WS, splash overflow

- BasicAuth.authenticate accepts the request and appends
  ?next=<full_path> when redirecting to /login, so deep links from
  /settings/, /system-info/, etc. round-trip the operator's original
  destination through the form. NoAuth.authenticate keeps the no-op
  behaviour. authenticate_if_needed threads the request through.
- login.html no longer extends base.html — same justification as the
  earlier _error.html change: base.html pulls vendor.js, which opens
  /ws on every render. login is a public pre-auth surface and has no
  business holding a WebSocket. Standalone shell mirrors _error.html.
- .splash-page drops `overflow: hidden`. The splash is opened from
  phones (QR scan) as well as the device webview; locking scroll
  hides the IP pills + QR when content spills past the viewport on
  smaller screens / larger system text.
- Add two test_auth.py cases covering the new ?next round-trip and
  the request-threading through authenticate_if_needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): only carry next for safe GET navigations

BasicAuth.authenticate previously attached ?next=<full_path> for
every unauthenticated request. Two cases that broke:

- Non-GET methods (POST/PUT/PATCH/DELETE): the post-login redirect
  bounces the browser back as a GET, hitting routes that only accept
  the original method → 405. Drop next for unsafe methods.
- htmx partial endpoints (HX-Request: true): the dashboard polls
  fragments such as assets_table_partial every 5s. A session expiry
  mid-poll would otherwise serialize the partial URL into next,
  landing the operator on a bare table fragment after sign-in
  instead of the parent page. Detect via the HX-Request header and
  drop next.

Extracts the heuristic into _is_safe_login_next_source() with two
new test_auth.py cases covering the unsafe-method and htmx-partial
paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:53:56 +01:00

96 lines
3.1 KiB
Python

"""Smoke coverage for the branded 4xx/5xx error templates.
Django only swaps in 400/403/404/500.html when DEBUG=False. The dev
test client runs with DEBUG=True by default, so a broken extends
chain, missing static reference, or context-processor-dependent tag
in 500.html would only surface during a real production outage.
These tests exercise the templates directly and via the prod handler
path.
"""
from __future__ import annotations
from typing import Callable
import pytest
from django.http import HttpResponse
from django.template.loader import get_template
from django.test import Client, RequestFactory, override_settings
from django.views.defaults import (
bad_request,
page_not_found,
permission_denied,
server_error,
)
_ERROR_TEMPLATES = [
'400.html',
'403.html',
'403_csrf.html',
'404.html',
'500.html',
]
@pytest.fixture
def rf() -> RequestFactory:
return RequestFactory()
@pytest.mark.parametrize('name', _ERROR_TEMPLATES)
def test_error_template_renders_without_request_context(name: str) -> None:
"""500.html in particular renders with no RequestContext (no context
processors, no `request`). All four must render with an empty
context — that's the strongest pre-prod check we can write."""
html = get_template(name).render({})
assert 'auth-card' in html
assert 'error-card' in html
# The dashboard CTA goes through {% url %}, so a successful render
# also proves the URL reverser is wired up.
assert 'href="/"' in html or 'href="/dashboard' in html
@override_settings(DEBUG=False, ALLOWED_HOSTS=['*'])
def test_404_handler_uses_branded_template() -> None:
"""End-to-end: with DEBUG off, an unknown URL hits Django's
default 404 handler, which loads our 404.html."""
client = Client()
response = client.get('/this-path-does-not-exist')
assert response.status_code == 404
body = response.content.decode()
assert 'Page not found' in body
assert 'auth-card' in body
@pytest.mark.parametrize(
'view, expected_status',
[
(bad_request, 400),
(permission_denied, 403),
(page_not_found, 404),
],
)
def test_default_handler_returns_branded_body(
rf: RequestFactory,
view: Callable[..., HttpResponse],
expected_status: int,
) -> None:
"""Django's default 4xx handlers render `<code>.html` from the
project template path. This bypasses URL routing and goes straight
at the handler — useful because page_not_found et al. take a
mandatory `exception` argument that the test client can't supply."""
request = rf.get('/anything')
response = view(request, exception=Exception('test'))
assert response.status_code == expected_status
assert b'auth-card' in response.content
def test_500_handler_returns_branded_body(rf: RequestFactory) -> None:
"""500's signature differs (no `exception` kwarg) and the renderer
skips context processors, so test it on its own."""
request = rf.get('/anything')
response = server_error(request)
assert response.status_code == 500
assert b'auth-card' in response.content
assert b'500' in response.content