feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""Frontend-Asset Tests: verify JS modules and CSS are syntactically valid
|
||||
and export / register the expected symbols.
|
||||
|
||||
These tests perform static text-level checks on the JS / CSS files — no
|
||||
runtime execution, no nodejs dependency. They are sufficient to catch:
|
||||
- Missing exports (e.g. `export { api, ApiError }` removed from api.js)
|
||||
- Missing global Alpine.data() registrations for the components
|
||||
- CSS variables removed from the custom-overrides file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
WEBUI = Path(__file__).resolve().parent.parent / "app" / "webui"
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
p = WEBUI / rel
|
||||
assert p.exists(), f"Missing asset: {p}"
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_js_exports_api_and_ApiError():
|
||||
content = _read("js/api.js")
|
||||
assert "export class ApiError" in content
|
||||
assert "export const api" in content or "export default api" in content
|
||||
|
||||
|
||||
def test_api_js_uses_localStorage_for_jwt():
|
||||
"""R-1 / architecture: JWT must live in localStorage."""
|
||||
content = _read("js/api.js")
|
||||
assert "localStorage" in content
|
||||
assert "jwt" in content
|
||||
|
||||
|
||||
def test_api_js_handles_401_redirect():
|
||||
"""401 must clear the token and redirect to /index.html."""
|
||||
content = _read("js/api.js")
|
||||
assert "401" in content
|
||||
assert "/index.html" in content
|
||||
|
||||
|
||||
def test_api_js_handles_204_no_content():
|
||||
content = _read("js/api.js")
|
||||
assert "204" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# store.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_store_js_defines_auth_and_notifications():
|
||||
content = _read("js/store.js")
|
||||
assert "Alpine.store('auth'" in content
|
||||
assert "Alpine.store('notifications'" in content
|
||||
|
||||
|
||||
def test_store_js_auth_has_login_and_logout():
|
||||
content = _read("js/store.js")
|
||||
assert "login" in content
|
||||
assert "logout" in content
|
||||
assert "fetchUser" in content
|
||||
|
||||
|
||||
def test_store_js_notifications_has_push_and_dismiss():
|
||||
content = _read("js/store.js")
|
||||
assert "push" in content
|
||||
assert "dismiss" in content
|
||||
assert "success" in content
|
||||
assert "error" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.css
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_app_css_contains_tailwind_overrides():
|
||||
content = _read("css/app.css")
|
||||
# The file defines custom CSS variables (--crm-primary, etc.)
|
||||
assert "--crm-primary" in content
|
||||
assert "--crm-success" in content
|
||||
assert "--crm-danger" in content
|
||||
|
||||
|
||||
def test_app_css_has_loading_spinner():
|
||||
content = _read("css/app.css")
|
||||
assert "crm-spinner" in content
|
||||
assert "@keyframes crm-spin" in content
|
||||
|
||||
|
||||
def test_app_css_has_toast_styles():
|
||||
content = _read("css/app.css")
|
||||
assert "crm-toast" in content
|
||||
assert "toast-success" in content
|
||||
assert "toast-error" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alpine components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMPONENTS = [
|
||||
("account-list.js", "accountList"),
|
||||
("deal-kanban.js", "dealKanban"),
|
||||
("activity-list.js", "activityList"),
|
||||
("dashboard-kpis.js", "dashboardKpis"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,factory", COMPONENTS)
|
||||
def test_alpine_component_defined(filename, factory):
|
||||
"""Each component file must define a named factory and register it
|
||||
globally with Alpine.data(name, factory)."""
|
||||
content = _read(f"components/{filename}")
|
||||
# factory function definition
|
||||
assert f"export function {factory}" in content, \
|
||||
f"{filename} missing `export function {factory}()`"
|
||||
# Alpine.data() registration
|
||||
assert f"Alpine.data('{factory}'" in content, \
|
||||
f"{filename} missing `Alpine.data('{factory}', …)` registration"
|
||||
|
||||
|
||||
def test_deal_kanban_handles_drag_and_drop():
|
||||
content = _read("components/deal-kanban.js")
|
||||
assert "draggable" in content or "dataTransfer" in content
|
||||
assert "onDrop" in content
|
||||
assert "/deals/" in content and "/stage" in content
|
||||
|
||||
|
||||
def test_activity_list_supports_overdue_filter():
|
||||
content = _read("components/activity-list.js")
|
||||
assert "overdue" in content
|
||||
assert "/activities/" in content
|
||||
assert "/complete" in content
|
||||
|
||||
|
||||
def test_dashboard_kpis_loads_both_kpis_and_feed():
|
||||
content = _read("components/dashboard-kpis.js")
|
||||
assert "/dashboard/kpis" in content
|
||||
assert "/dashboard/feed" in content
|
||||
|
||||
|
||||
def test_account_list_uses_pagination():
|
||||
content = _read("components/account-list.js")
|
||||
assert "skip" in content
|
||||
assert "limit" in content
|
||||
assert "q" in content
|
||||
assert "/accounts/" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notifications.js (toast component)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_notifications_js_registers_toast_container():
|
||||
content = _read("js/components/notifications.js")
|
||||
assert "toastContainer" in content
|
||||
assert "Alpine.data" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# auth.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auth_js_exports_login_logout_register():
|
||||
content = _read("js/auth.js")
|
||||
assert "export async function login" in content
|
||||
assert "export async function logout" in content
|
||||
assert "export async function register" in content
|
||||
assert "export async function refreshToken" in content
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Frontend-Smoke-Tests: verify all 13 HTML pages exist and contain the
|
||||
expected keywords/structure.
|
||||
|
||||
These tests read the HTML files directly from `app/webui/` (rather than
|
||||
hitting the FastAPI server) because the static-file mount is added in a
|
||||
later phase by the orchestrator. Once that mount is in place, the same
|
||||
checks could be re-implemented as `httpx.AsyncClient.get("/…")`.
|
||||
|
||||
Each test loads exactly one page and asserts:
|
||||
- file exists and is non-empty
|
||||
- contains the page's title text (so a typo / removal is caught)
|
||||
- has Tailwind-CDN, Alpine-CDN, and the app.css link
|
||||
- has no x-html attribute (R-5)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
WEBUI_DIR = Path(__file__).resolve().parent.parent / "app" / "webui"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
"""Read a file from the webui directory."""
|
||||
p = WEBUI_DIR / name
|
||||
assert p.exists(), f"Expected frontend file {p} to exist"
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each page: (filename, [required substrings])
|
||||
# ---------------------------------------------------------------------------
|
||||
PAGES = [
|
||||
("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]),
|
||||
("app.html", ["Dashboard", "Logout", "Pipeline"]),
|
||||
("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]),
|
||||
("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]),
|
||||
("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]),
|
||||
("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]),
|
||||
("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]),
|
||||
("activities.html", ["Activities", "+ New Activity", "Fällig"]),
|
||||
("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]),
|
||||
("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]),
|
||||
("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]),
|
||||
("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]),
|
||||
# Dashboard: ships inside app.html. We test the marker text directly there.
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,required", PAGES)
|
||||
def test_frontend_page_loads(filename, required):
|
||||
"""Each HTML page must exist and contain its identifying substrings."""
|
||||
content = _read(filename)
|
||||
assert len(content) > 200, f"{filename} seems empty or too small"
|
||||
for needle in required:
|
||||
assert needle in content, f"{filename} missing required string {needle!r}"
|
||||
|
||||
|
||||
def test_index_html_contains_login_and_register():
|
||||
"""Index page (auth screen) must show both Login and Register tabs."""
|
||||
content = _read("index.html")
|
||||
assert "Login" in content
|
||||
assert "Registrieren" in content
|
||||
# Alpine-CDN required
|
||||
assert "alpinejs" in content
|
||||
|
||||
|
||||
def test_app_html_contains_dashboard_and_logout():
|
||||
"""app.html is the post-login shell — must show Dashboard + Logout."""
|
||||
content = _read("app.html")
|
||||
assert "Dashboard" in content
|
||||
assert "Logout" in content
|
||||
|
||||
|
||||
def test_13_pages_exist():
|
||||
"""Exactly 13 HTML pages must be present (index + app + 10 + 404)."""
|
||||
pages = sorted(p.name for p in WEBUI_DIR.glob("*.html"))
|
||||
assert len(pages) == 13, f"Expected 13 pages, found {len(pages)}: {pages}"
|
||||
|
||||
|
||||
def test_dashboard_section_inside_app_html():
|
||||
"""The dashboard content is rendered inside app.html (default landing)."""
|
||||
content = _read("app.html")
|
||||
# The 4 KPI labels from the dashboard section
|
||||
assert "Open Deals" in content
|
||||
assert "Pipeline Value" in content
|
||||
assert "Won this Month" in content
|
||||
assert "Conversion Rate" in content
|
||||
|
||||
|
||||
def test_each_page_links_app_css():
|
||||
"""Every page must reference /css/app.css."""
|
||||
for filename, _ in PAGES:
|
||||
content = _read(filename)
|
||||
assert "/css/app.css" in content, f"{filename} missing /css/app.css link"
|
||||
|
||||
|
||||
def test_each_page_includes_alpine_cdn():
|
||||
"""Every page must include the Alpine.js CDN script."""
|
||||
for filename, _ in PAGES:
|
||||
content = _read(filename)
|
||||
assert "alpinejs" in content, f"{filename} missing Alpine.js CDN"
|
||||
|
||||
|
||||
def test_each_page_includes_tailwind_cdn():
|
||||
"""Every page must include the Tailwind-CDN script (dev-mode)."""
|
||||
for filename, _ in PAGES:
|
||||
content = _read(filename)
|
||||
assert "cdn.tailwindcss.com" in content, f"{filename} missing Tailwind CDN"
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Frontend-Security Tests: XSS-mitigation, JWT-handling, CSP-middleware.
|
||||
|
||||
These tests enforce the rules from architecture Section 13 + task graph R-5:
|
||||
|
||||
- R-5: No `x-html` attribute anywhere in any HTML template (only x-text).
|
||||
- R-1: JWT must be read from / written to localStorage (not cookies, not sessionStorage).
|
||||
- 13.3: CSP-Header is configured in app/main.py (security_headers_middleware),
|
||||
not in a reverse-proxy config file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WEBUI = ROOT / "app" / "webui"
|
||||
MAIN_PY = ROOT / "app" / "main.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R-5: no x-html in any HTML template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Match `x-html` as a whole word (attribute, not part of `x-htmlSomething`).
|
||||
# We deliberately allow `x-html="..."` to fail loudly, and we also catch
|
||||
# any camel-cased variant like `xHtml` for paranoia.
|
||||
XHTML_PATTERN = re.compile(r"\bx[-_]?html\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _all_html_files() -> list[Path]:
|
||||
return sorted(WEBUI.glob("*.html"))
|
||||
|
||||
|
||||
def test_no_x_html_in_alpine_templates():
|
||||
"""R-5: no `x-html` attribute anywhere in any HTML file."""
|
||||
offenders: list[tuple[str, int, str]] = []
|
||||
for path in _all_html_files():
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
# Strip HTML comments to avoid false positives in documentation
|
||||
stripped = re.sub(r"<!--.*?-->", "", line)
|
||||
if XHTML_PATTERN.search(stripped):
|
||||
offenders.append((path.name, lineno, line.strip()[:120]))
|
||||
assert not offenders, (
|
||||
"x-html usage is forbidden (R-5); offenders:\n"
|
||||
+ "\n".join(f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_no_innerHTML_in_alpine_pages():
|
||||
"""Defence in depth: also flag direct `innerHTML` assignments."""
|
||||
for path in _all_html_files():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# allow within <script> blocks only if commented out / disabled
|
||||
assert "innerHTML" not in content, \
|
||||
f"{path.name} contains innerHTML which is forbidden (R-5)"
|
||||
|
||||
|
||||
def test_x_text_is_used_instead():
|
||||
"""Sanity check: the auth/dashboard pages use x-text for user-derived strings."""
|
||||
index = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||
# The error message div should use x-text (so user input never gets HTML-parsed)
|
||||
assert "x-text=\"error\"" in index or 'x-text="error"' in index
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R-1: JWT in localStorage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_jwt_uses_localStorage():
|
||||
"""api.js must read/write the JWT in localStorage, not cookies/sessionStorage."""
|
||||
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
||||
# Must read the token from localStorage
|
||||
assert "localStorage.getItem('jwt')" in api
|
||||
# Must persist the token back to localStorage on login
|
||||
assert "localStorage.setItem('jwt'" in api
|
||||
# Must clear on logout
|
||||
assert "localStorage.removeItem('jwt')" in api
|
||||
|
||||
|
||||
def test_jwt_not_in_sessionStorage():
|
||||
"""Defence in depth: do NOT use sessionStorage for the JWT."""
|
||||
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
||||
assert "sessionStorage" not in api, "api.js must not use sessionStorage for the JWT"
|
||||
|
||||
|
||||
def test_jwt_not_in_cookies():
|
||||
"""Defence in depth: do NOT put the JWT in document.cookie."""
|
||||
for js in WEBUI.rglob("*.js"):
|
||||
content = js.read_text(encoding="utf-8")
|
||||
assert "document.cookie" not in content, \
|
||||
f"{js.relative_to(ROOT)} must not use document.cookie for the JWT"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13.3: CSP-Header in main.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_csp_header_in_main_py():
|
||||
"""app/main.py must wire up a middleware (or response-header decorator)
|
||||
that sets Content-Security-Policy. We don't run the server here — we
|
||||
just verify the configuration is present in code."""
|
||||
assert MAIN_PY.exists(), f"Missing {MAIN_PY}"
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
# The middleware / decorator must mention the CSP header value
|
||||
assert "Content-Security-Policy" in content, \
|
||||
"app/main.py does not set Content-Security-Policy"
|
||||
# The header must allow the Tailwind CDN at minimum
|
||||
assert "cdn.tailwindcss.com" in content, \
|
||||
"CSP-Header must allow https://cdn.tailwindcss.com in script-src"
|
||||
# And block scripts from arbitrary origins
|
||||
assert "default-src 'self'" in content or 'default-src "self"' in content, \
|
||||
"CSP-Header must set default-src 'self'"
|
||||
|
||||
|
||||
def test_csp_blocks_object_embedding():
|
||||
"""object-src 'none' is part of the agreed lockdown."""
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
assert "object-src 'none'" in content, \
|
||||
"CSP-Header must set object-src 'none'"
|
||||
|
||||
|
||||
def test_csp_uses_starlette_middleware():
|
||||
"""Per architecture: CSP is set in a FastAPI/Starlette middleware,
|
||||
not in nginx / a response handler."""
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
assert "@app.middleware(\"http\")" in content or '@app.middleware("http")' in content, \
|
||||
"CSP-Header must be set in an @app.middleware(\"http\") decorator"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-gate on protected pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROTECTED_PAGES = [
|
||||
"accounts.html",
|
||||
"accounts-detail.html",
|
||||
"contacts.html",
|
||||
"contacts-detail.html",
|
||||
"pipeline.html",
|
||||
"activities.html",
|
||||
"settings-profile.html",
|
||||
"settings-users.html",
|
||||
"settings-org.html",
|
||||
"app.html",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", PROTECTED_PAGES)
|
||||
def test_protected_page_has_auth_gate(filename):
|
||||
"""Each non-public page must call Alpine.store('auth').init() in its
|
||||
outermost x-data, which acts as the auth-gate."""
|
||||
content = (WEBUI / filename).read_text(encoding="utf-8")
|
||||
# The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"`
|
||||
# or `x-init="init()"` on a component that checks the JWT.
|
||||
assert "Alpine.store('auth').init()" in content or \
|
||||
"$store.auth.init()" in content, \
|
||||
f"{filename} has no Alpine.store('auth').init() auth-gate"
|
||||
|
||||
|
||||
def test_index_page_has_no_auth_gate():
|
||||
"""index.html is the LOGIN page itself — it must NOT redirect to itself."""
|
||||
content = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||
# The auth store's init() redirects to /index.html when no JWT exists,
|
||||
# so it must NOT be called from the login page.
|
||||
assert "Alpine.store('auth').init()" not in content, \
|
||||
"index.html (login page) must not call auth.init() (would cause loop)"
|
||||
|
||||
|
||||
def test_404_page_has_no_auth_gate():
|
||||
"""404 page is reachable without auth (so users can find their way back)."""
|
||||
content = (WEBUI / "404.html").read_text(encoding="utf-8")
|
||||
# The 404 must not call auth.init() — it is intentionally public.
|
||||
assert "Alpine.store('auth').init()" not in content, \
|
||||
"404.html must not call auth.init() (public page)"
|
||||
Reference in New Issue
Block a user