feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests

This commit is contained in:
CRM Bot
2026-06-03 22:02:56 +00:00
parent 53cbcde729
commit 0f11e4a836
26 changed files with 4338 additions and 0 deletions
+177
View File
@@ -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
+111
View File
@@ -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"
+177
View File
@@ -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)"