395 lines
15 KiB
Python
395 lines
15 KiB
Python
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.frontend.router import router
|
|
from app.identity import (
|
|
get_current_user,
|
|
router as identity_router,
|
|
safe_next_path,
|
|
install_identity_middleware,
|
|
protected_page,
|
|
required_page_permission,
|
|
)
|
|
|
|
|
|
ROUTES = (
|
|
"/dashboard",
|
|
"/monitoring/map",
|
|
"/operations/repairs",
|
|
"/assets/miners",
|
|
)
|
|
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
|
|
|
|
|
|
class FrontendShellTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = FastAPI()
|
|
self.app.include_router(router)
|
|
self.client = TestClient(self.app)
|
|
|
|
def tearDown(self):
|
|
self.client.close()
|
|
|
|
def authenticate(self, permissions=None, role="Read Only"):
|
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
|
"id": "00000000-0000-0000-0000-000000000321",
|
|
"username": "operator",
|
|
"display_name": "Floor Operator",
|
|
"role": role,
|
|
"permissions": list(
|
|
permissions
|
|
or (
|
|
"dashboard.view",
|
|
"locations.view",
|
|
"repairs.view",
|
|
"miners.view",
|
|
)
|
|
),
|
|
}
|
|
|
|
def test_routes_require_authentication(self):
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
self.assertEqual(self.client.get(path).status_code, 401)
|
|
|
|
def test_all_direct_routes_render_authenticated_shell(self):
|
|
self.authenticate()
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
response = self.client.get(path)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn("MinerOps", response.text)
|
|
self.assertIn(
|
|
"Operational Intelligence for Mining",
|
|
response.text,
|
|
)
|
|
self.assertIn("Floor Operator", response.text)
|
|
self.assertIn(
|
|
'aria-label="Primary navigation"',
|
|
response.text,
|
|
)
|
|
|
|
def test_navigation_connects_all_internal_alpha_views(self):
|
|
self.authenticate()
|
|
response = self.client.get("/dashboard")
|
|
for path in ROUTES:
|
|
self.assertIn(f'href="{path}"', response.text)
|
|
self.assertIn('aria-current="page"', response.text)
|
|
|
|
def test_each_route_requires_its_specific_permission(self):
|
|
cases = (
|
|
("/dashboard", "dashboard.view"),
|
|
("/monitoring/map", "locations.view"),
|
|
("/operations/repairs", "repairs.view"),
|
|
("/assets/miners", "miners.view"),
|
|
)
|
|
for path, permission in cases:
|
|
with self.subTest(path=path):
|
|
self.authenticate(permissions=("unrelated.permission",))
|
|
self.assertEqual(self.client.get(path).status_code, 403)
|
|
self.authenticate(permissions=(permission,))
|
|
self.assertEqual(self.client.get(path).status_code, 200)
|
|
|
|
def test_main_authentication_gate_redirects_to_existing_login(self):
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
install_identity_middleware(app)
|
|
with TestClient(app) as client, patch(
|
|
"app.identity.request_user_or_none",
|
|
return_value=None,
|
|
):
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
response = client.get(path, follow_redirects=False)
|
|
self.assertEqual(response.status_code, 303)
|
|
self.assertTrue(
|
|
response.headers["location"].startswith(
|
|
"/login?next="
|
|
)
|
|
)
|
|
|
|
def test_page_permission_mapping_covers_new_routes(self):
|
|
expected = {
|
|
"/dashboard": "dashboard.view",
|
|
"/monitoring/map": "locations.view",
|
|
"/operations/repairs": "repairs.view",
|
|
"/assets/miners": "miners.view",
|
|
}
|
|
for path, permission in expected.items():
|
|
with self.subTest(path=path):
|
|
self.assertTrue(protected_page(path))
|
|
self.assertEqual(
|
|
required_page_permission(path),
|
|
permission,
|
|
)
|
|
|
|
def test_dashboard_does_not_fabricate_operational_values(self):
|
|
self.authenticate()
|
|
response = self.client.get("/dashboard")
|
|
self.assertIn(
|
|
"Live health summary not yet connected",
|
|
response.text,
|
|
)
|
|
self.assertIn("No events are fabricated", response.text)
|
|
self.assertNotIn("100 TH/s", response.text)
|
|
|
|
def test_map_is_a_structured_honest_placeholder(self):
|
|
self.authenticate()
|
|
response = self.client.get("/monitoring/map")
|
|
for label in (
|
|
"Site",
|
|
"Room",
|
|
"Rack",
|
|
"Status layer",
|
|
"Selection details",
|
|
):
|
|
self.assertIn(label, response.text)
|
|
self.assertIn(
|
|
"Live layout and telemetry data are not",
|
|
response.text,
|
|
)
|
|
self.assertIn(
|
|
"connected to this Internal Alpha canvas",
|
|
response.text,
|
|
)
|
|
|
|
def test_data_views_include_all_request_states(self):
|
|
self.authenticate()
|
|
for path in ("/operations/repairs", "/assets/miners"):
|
|
with self.subTest(path=path):
|
|
response_text = self.client.get(path).text
|
|
for state in (
|
|
"loading",
|
|
"empty",
|
|
"error",
|
|
"denied",
|
|
"ready",
|
|
):
|
|
self.assertIn(
|
|
f'data-view-state="{state}"',
|
|
response_text,
|
|
)
|
|
|
|
def test_frontend_assets_are_responsive_and_accessible(self):
|
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
|
javascript = (STATIC_ROOT / "app.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("@media (max-width: 560px)", css)
|
|
self.assertIn(":focus-visible", css)
|
|
self.assertIn(".skip-link", css)
|
|
self.assertIn('response.status === 401', javascript)
|
|
self.assertIn('response.status === 403', javascript)
|
|
self.assertIn('credentials: "same-origin"', javascript)
|
|
|
|
def test_static_assets_are_served_without_an_api_route(self):
|
|
css_response = self.client.get("/assets/minerops-app.css")
|
|
script_response = self.client.get("/assets/minerops-app.js")
|
|
self.assertEqual(css_response.status_code, 200)
|
|
self.assertEqual(
|
|
css_response.headers["content-type"],
|
|
"text/css; charset=utf-8",
|
|
)
|
|
self.assertEqual(script_response.status_code, 200)
|
|
paths = {route.path for route in router.routes}
|
|
self.assertFalse(any(path.startswith("/api/") for path in paths))
|
|
|
|
def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self):
|
|
from app.main import app
|
|
|
|
first_handlers = {}
|
|
for route in app.routes:
|
|
if route.path not in first_handlers:
|
|
first_handlers[route.path] = route.endpoint
|
|
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
self.assertEqual(
|
|
first_handlers[path].__module__,
|
|
"app.frontend.router",
|
|
)
|
|
for path in (
|
|
"/repairs",
|
|
"/locations",
|
|
"/miners/{miner_id}/detail",
|
|
):
|
|
with self.subTest(legacy_path=path):
|
|
self.assertIn(path, first_handlers)
|
|
|
|
|
|
def test_safe_next_path_accepts_only_protected_local_pages(self):
|
|
valid = (
|
|
"/dashboard",
|
|
"/assets/miners?query=S19%20Pro",
|
|
"/miners/00000000-0000-0000-0000-000000000123/detail",
|
|
)
|
|
for value in valid:
|
|
with self.subTest(valid=value):
|
|
self.assertEqual(safe_next_path(value), value)
|
|
|
|
invalid = (
|
|
None,
|
|
"",
|
|
"https://example.invalid/dashboard",
|
|
"//example.invalid/dashboard",
|
|
r"\\example.invalid\dashboard",
|
|
"/../login",
|
|
"/assets/%2f%2fexample.invalid",
|
|
"/dashboard#fragment",
|
|
"/login",
|
|
"/api/inventory/miners",
|
|
" /dashboard",
|
|
"/dashboard\n",
|
|
)
|
|
for value in invalid:
|
|
with self.subTest(invalid=value):
|
|
self.assertEqual(safe_next_path(value), "/dashboard")
|
|
|
|
def test_login_redirect_preserves_direct_link_query(self):
|
|
app = FastAPI()
|
|
app.include_router(identity_router)
|
|
app.include_router(router)
|
|
install_identity_middleware(app)
|
|
with TestClient(app) as client, patch(
|
|
"app.identity.request_user_or_none",
|
|
return_value=None,
|
|
):
|
|
response = client.get(
|
|
"/assets/miners?query=S19%20Pro",
|
|
follow_redirects=False,
|
|
)
|
|
self.assertEqual(response.status_code, 303)
|
|
location = urlsplit(response.headers["location"])
|
|
self.assertEqual(location.path, "/login")
|
|
self.assertEqual(
|
|
parse_qs(location.query)["next"],
|
|
["/assets/miners?query=S19%20Pro"],
|
|
)
|
|
|
|
def test_authenticated_login_redirect_rejects_unsafe_next(self):
|
|
app = FastAPI()
|
|
app.include_router(identity_router)
|
|
install_identity_middleware(app)
|
|
with TestClient(app) as client, patch(
|
|
"app.identity.request_user_or_none",
|
|
return_value={"id": "user"},
|
|
):
|
|
for value in (
|
|
"https://example.invalid",
|
|
"//example.invalid",
|
|
r"\\example.invalid\dashboard",
|
|
"/../login",
|
|
):
|
|
with self.subTest(value=value):
|
|
response = client.get(
|
|
"/login",
|
|
params={"next": value},
|
|
follow_redirects=False,
|
|
)
|
|
self.assertEqual(response.status_code, 303)
|
|
self.assertEqual(
|
|
response.headers["location"],
|
|
"/dashboard",
|
|
)
|
|
|
|
def test_login_template_escapes_safe_query_destination(self):
|
|
app = FastAPI()
|
|
app.include_router(identity_router)
|
|
hostile = '/assets/miners?query="><script>alert(1)</script>'
|
|
with TestClient(app) as client:
|
|
response = client.get("/login", params={"next": hostile})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertNotIn(hostile, response.text)
|
|
self.assertIn(""><script>", response.text)
|
|
self.assertNotIn("new URLSearchParams(location.search)", response.text)
|
|
|
|
def test_user_values_are_html_escaped(self):
|
|
hostile = '<img src=x onerror=alert(1)>'
|
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
|
"id": "00000000-0000-0000-0000-000000000321",
|
|
"username": "operator",
|
|
"display_name": hostile,
|
|
"role": "Read Only",
|
|
"permissions": ["dashboard.view", "miners.view"],
|
|
}
|
|
response = self.client.get("/dashboard")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertNotIn(hostile, response.text)
|
|
self.assertIn("<img src=x onerror=alert(1)>", response.text)
|
|
|
|
def test_navigation_and_search_are_permission_aware(self):
|
|
self.authenticate(permissions=("dashboard.view",))
|
|
response = self.client.get("/dashboard")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn('href="/dashboard"', response.text)
|
|
for path in ROUTES[1:]:
|
|
self.assertNotIn(f'href="{path}"', response.text)
|
|
self.assertNotIn('id="globalSearchForm"', response.text)
|
|
self.assertIn('data-can-view-miners="false"', response.text)
|
|
|
|
def test_assets_disable_caching_and_missing_assets_are_404(self):
|
|
for path in (
|
|
"/assets/minerops-app.css",
|
|
"/assets/minerops-app.js",
|
|
):
|
|
with self.subTest(path=path):
|
|
response = self.client.get(path)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
|
self.assertEqual(
|
|
self.client.get("/assets/missing-minerops-file.js").status_code,
|
|
404,
|
|
)
|
|
|
|
def test_trailing_slash_redirects_are_consistent(self):
|
|
self.authenticate()
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
response = self.client.get(
|
|
path + "/",
|
|
follow_redirects=False,
|
|
)
|
|
self.assertEqual(response.status_code, 307)
|
|
self.assertEqual(
|
|
urlsplit(response.headers["location"]).path,
|
|
path,
|
|
)
|
|
|
|
def test_shell_markup_has_accessibility_landmarks(self):
|
|
self.authenticate()
|
|
for path in ROUTES:
|
|
with self.subTest(path=path):
|
|
text = self.client.get(path).text
|
|
self.assertEqual(text.count("<h1>"), 1)
|
|
self.assertIn('class="skip-link"', text)
|
|
self.assertIn('<main class="main-content"', text)
|
|
self.assertIn('aria-label="Primary navigation"', text)
|
|
self.assertIn('aria-controls="primaryNav"', text)
|
|
|
|
def test_javascript_uses_safe_dom_and_strict_failure_states(self):
|
|
javascript = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
|
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
|
|
self.assertNotIn(unsafe, javascript)
|
|
self.assertIn("element.textContent = values.text", javascript)
|
|
self.assertIn("cell.textContent = text(content)", javascript)
|
|
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
|
|
self.assertIn("encodeURIComponent(miner.id)", javascript)
|
|
self.assertIn("new AbortController()", javascript)
|
|
self.assertIn("invalid repair response", javascript)
|
|
self.assertIn("invalid inventory response", javascript)
|
|
self.assertIn("window.location.replace", javascript)
|
|
|
|
def test_styles_have_no_motion_to_reduce(self):
|
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
|
self.assertNotIn("animation:", css)
|
|
self.assertNotIn("transition:", css)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|