v1.5.4: test infrastructure + bug fixes + CI/CD + deploy gate
- 6 new test files: route discovery, strict permissions, role switch, CRUD operations, 500 error protection - permission_config.py: central permission definitions for all 110+ routes - Bug fixes: is_active checkbox in users_edit, int(id) 500 crash, missing validation in users_create/users_delete - Pre-commit hooks: lint + static routing tests - GitHub Actions CI: full pytest suite on push - Deploy gate: pytest runs before deploy, aborts on failure - All 3 deploy scripts: --skip-tests flag support
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Pytest configuration for service portal tests.
|
||||
|
||||
Provides authenticated httpx clients for testing against a running server.
|
||||
Set TEST_SERVER_URL env var to override the default http://localhost:8000.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:8000")
|
||||
PASSWORD = "AegisOne2024!"
|
||||
|
||||
|
||||
def _login(role: str) -> httpx.Client:
|
||||
"""Create an authenticated httpx client for the given role."""
|
||||
client = httpx.Client(base_url=SERVER_URL, follow_redirects=False, timeout=10)
|
||||
resp = client.post("/service/login", data={"login": role, "password": PASSWORD})
|
||||
assert resp.status_code == 302, f"{role} login failed: {resp.status_code} {resp.text[:200]}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Unauthenticated httpx client."""
|
||||
with httpx.Client(base_url=SERVER_URL, follow_redirects=False, timeout=10) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def owner_client(_check_server):
|
||||
"""Authenticated httpx client as owner."""
|
||||
return _login("owner")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engineer_client(_check_server):
|
||||
"""Authenticated httpx client as engineer."""
|
||||
return _login("engineer")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def technician_client(_check_server):
|
||||
"""Authenticated httpx client as technician."""
|
||||
return _login("technician")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def api_client(_check_server):
|
||||
"""Authenticated httpx client as owner for API tests."""
|
||||
return _login("owner")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _check_server():
|
||||
"""Sanity check — server must be reachable before running tests."""
|
||||
with httpx.Client(base_url=SERVER_URL, timeout=5) as c:
|
||||
resp = c.get("/health")
|
||||
if resp.status_code != 200:
|
||||
pytest.fail(f"Server at {SERVER_URL} is not reachable (health: {resp.status_code})")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def role_client(client, owner_client, engineer_client, technician_client):
|
||||
"""Dict mapping role names to authenticated httpx clients."""
|
||||
return {
|
||||
"anonymous": client,
|
||||
"owner": owner_client,
|
||||
"engineer": engineer_client,
|
||||
"technician": technician_client,
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Central permission configuration for all service routes.
|
||||
|
||||
Every route must be listed here. test_00_discover_routes.py enforces this.
|
||||
|
||||
Format:
|
||||
ROUTE_PERMISSIONS[(method, path)] = (role1, role2, ...)
|
||||
- "anonymous" means no auth required (public)
|
||||
- "authenticated" means any logged-in user
|
||||
|
||||
Adding a new route? Add it here first — the discover test will fail otherwise.
|
||||
"""
|
||||
|
||||
ROLES = ("owner", "engineer", "technician")
|
||||
|
||||
ROUTE_PERMISSIONS: dict[tuple[str, str], tuple[str, ...]] = {
|
||||
# === PUBLIC ===
|
||||
("GET", "/health"): ("anonymous",),
|
||||
("GET", "/service/login"): ("anonymous",),
|
||||
("POST", "/service/login"): ("anonymous",),
|
||||
("GET", "/"): ("anonymous",),
|
||||
|
||||
# === AUTH ===
|
||||
("GET", "/service/logout"): ("authenticated",),
|
||||
|
||||
# === DASHBOARD ===
|
||||
("GET", "/service/dashboard"): ("owner", "engineer", "technician"),
|
||||
|
||||
# === USERS ===
|
||||
("GET", "/service/users"): ("owner",),
|
||||
("POST", "/service/users/create"): ("owner",),
|
||||
("POST", "/service/users/edit"): ("owner",),
|
||||
("POST", "/service/users/delete"): ("owner",),
|
||||
|
||||
# === CUSTOMERS ===
|
||||
("GET", "/service/customers"): ("owner", "engineer"),
|
||||
("POST", "/service/customers/create"): ("owner", "engineer"),
|
||||
("POST", "/service/customers/edit"): ("owner", "engineer"),
|
||||
("POST", "/service/customers/delete"): ("owner",),
|
||||
|
||||
# === OBJECTS ===
|
||||
("GET", "/service/objects"): ("owner", "engineer"),
|
||||
("POST", "/service/objects/create"): ("owner", "engineer"),
|
||||
("POST", "/service/objects/edit"): ("owner", "engineer"),
|
||||
("POST", "/service/objects/delete"): ("owner",),
|
||||
|
||||
# === ASSIGNMENTS ===
|
||||
("GET", "/service/assignments"): ("owner",),
|
||||
("POST", "/service/assignments/create"): ("owner",),
|
||||
("POST", "/service/assignments/unassign"): ("owner",),
|
||||
|
||||
# === SLA ===
|
||||
("GET", "/service/sla"): ("owner", "engineer"),
|
||||
("POST", "/service/sla/create"): ("owner", "engineer"),
|
||||
("POST", "/service/sla/edit"): ("owner", "engineer"),
|
||||
|
||||
# === CEO ===
|
||||
("GET", "/service/ceo"): ("owner",),
|
||||
("POST", "/service/ceo/calc"): ("owner",),
|
||||
("POST", "/service/ceo/kpi"): ("owner",),
|
||||
|
||||
# === COEFFICIENTS ===
|
||||
("GET", "/service/coefficients"): ("owner",),
|
||||
("POST", "/service/coefficients/edit"): ("owner",),
|
||||
("POST", "/service/coefficients/toggle"): ("owner",),
|
||||
("POST", "/service/coefficients/create"): ("owner",),
|
||||
("POST", "/service/coefficients/delete"): ("owner",),
|
||||
("GET", "/service/coefficients/test"): ("owner",),
|
||||
("POST", "/service/coefficients/test/{formula_name}"): ("owner",),
|
||||
|
||||
# === FORMULAS ===
|
||||
("GET", "/service/formulas"): ("owner",),
|
||||
|
||||
# === TASKS ===
|
||||
("GET", "/service/tasks"): ("owner", "engineer", "technician"),
|
||||
("POST", "/service/tasks/create"): ("owner", "engineer"),
|
||||
("POST", "/service/tasks/close"): ("owner", "engineer", "technician"),
|
||||
|
||||
# === REPORTS ===
|
||||
("GET", "/service/reports"): ("owner", "engineer", "technician"),
|
||||
("POST", "/service/reports/create"): ("owner", "engineer", "technician"),
|
||||
|
||||
# === INCIDENTS ===
|
||||
("GET", "/service/incidents"): ("owner", "engineer", "technician"),
|
||||
("POST", "/service/incidents/create"): ("owner", "engineer"),
|
||||
("POST", "/service/incidents/resolve"): ("owner", "engineer", "technician"),
|
||||
|
||||
# === QUESTIONNAIRE ===
|
||||
("GET", "/service/questionnaire"): ("owner", "engineer"),
|
||||
("GET", "/service/questionnaire/step/{step}"): ("owner", "engineer"),
|
||||
("POST", "/service/questionnaire/save/{step}"): ("owner", "engineer"),
|
||||
|
||||
# === QUESTIONNAIRE CONFIG ===
|
||||
("GET", "/service/questionnaire-config"): ("owner",),
|
||||
("POST", "/service/questionnaire-config/create"): ("owner",),
|
||||
("POST", "/service/questionnaire-config/edit"): ("owner",),
|
||||
("POST", "/service/questionnaire-config/toggle"): ("owner",),
|
||||
("POST", "/service/questionnaire-config/delete"): ("owner",),
|
||||
|
||||
# === PASSPORTS ===
|
||||
("GET", "/service/passports"): ("owner", "engineer"),
|
||||
|
||||
# === CHECKLIST ===
|
||||
("GET", "/service/checklist"): ("owner", "technician"),
|
||||
("POST", "/service/checklist/complete"): ("owner", "technician"),
|
||||
|
||||
# === BLOG ===
|
||||
("GET", "/service/blog"): ("owner",),
|
||||
("POST", "/service/blog/create"): ("owner",),
|
||||
("POST", "/service/blog/edit"): ("owner",),
|
||||
("POST", "/service/blog/delete"): ("owner",),
|
||||
|
||||
# === QUICK MENU ===
|
||||
("GET", "/service/quick-menu"): ("owner", "engineer"),
|
||||
("POST", "/service/quick-menu/save"): ("owner", "engineer"),
|
||||
("GET", "/service/api/quick-menu"): ("authenticated",),
|
||||
("GET", "/service/quick-menu/tech"): ("engineer",),
|
||||
("POST", "/service/quick-menu/tech-save"): ("engineer",),
|
||||
|
||||
# === IDEAS ===
|
||||
("GET", "/service/ideas"): ("owner",),
|
||||
("POST", "/service/ideas/create"): ("owner",),
|
||||
("POST", "/service/ideas/edit"): ("owner",),
|
||||
("POST", "/service/ideas/status"): ("owner",),
|
||||
("POST", "/service/ideas/delete"): ("owner",),
|
||||
|
||||
# === CHARTS ===
|
||||
("GET", "/service/charts"): ("owner",),
|
||||
("GET", "/service/api/charts/data"): ("owner",),
|
||||
|
||||
# === CASES ===
|
||||
("GET", "/service/cases"): ("owner",),
|
||||
("POST", "/service/cases/create"): ("owner",),
|
||||
("POST", "/service/cases/edit"): ("owner",),
|
||||
("POST", "/service/cases/delete"): ("owner",),
|
||||
|
||||
# === BOT SETTINGS (pages) ===
|
||||
("GET", "/service/bot-settings"): ("owner",),
|
||||
("GET", "/service/bot-settings/responses"): ("owner",),
|
||||
("GET", "/service/bot-settings/features"): ("owner",),
|
||||
("GET", "/service/bot-settings/categories"): ("owner",),
|
||||
("GET", "/service/bot-settings/kb"): ("owner",),
|
||||
("GET", "/service/bot-settings/conversations"): ("owner",),
|
||||
("GET", "/service/bot-settings/users"): ("owner",),
|
||||
("GET", "/service/bot-settings/test-runner"): ("owner",),
|
||||
("GET", "/service/bot-settings/analytics"): ("owner",),
|
||||
|
||||
# === PORTAL / ROLE SETTINGS ===
|
||||
("GET", "/service/portal-settings"): ("owner",),
|
||||
("GET", "/service/role-settings"): ("owner",),
|
||||
|
||||
# === ROLE PERMISSIONS API ===
|
||||
("GET", "/service/api/role-permissions"): ("owner",),
|
||||
("POST", "/service/api/role-permissions/save"): ("owner",),
|
||||
|
||||
# === ROLE SWITCH API ===
|
||||
("POST", "/service/api/role/switch"): ("owner", "engineer"),
|
||||
("POST", "/service/api/role/reset"): ("authenticated",),
|
||||
|
||||
# === BOT API (proxied) ===
|
||||
("GET", "/service/api/bot/settings"): ("owner",),
|
||||
("POST", "/service/api/bot/settings/save"): ("owner",),
|
||||
("GET", "/service/api/bot/templates"): ("owner",),
|
||||
("POST", "/service/api/bot/templates/save"): ("owner",),
|
||||
("GET", "/service/api/bot/features"): ("owner",),
|
||||
("POST", "/service/api/bot/features/toggle"): ("owner",),
|
||||
("GET", "/service/api/bot/categories"): ("owner",),
|
||||
("POST", "/service/api/bot/categories/create"): ("owner",),
|
||||
("POST", "/service/api/bot/categories/edit"): ("owner",),
|
||||
("POST", "/service/api/bot/categories/delete"): ("owner",),
|
||||
("GET", "/service/api/bot/kb"): ("owner",),
|
||||
("POST", "/service/api/bot/kb/create"): ("owner",),
|
||||
("POST", "/service/api/bot/kb/edit"): ("owner",),
|
||||
("POST", "/service/api/bot/kb/delete"): ("owner",),
|
||||
("GET", "/service/api/bot/conversations"): ("owner",),
|
||||
("GET", "/service/api/bot/users"): ("owner",),
|
||||
("GET", "/service/api/bot/analytics"): ("owner",),
|
||||
("POST", "/service/api/bot/test-run"): ("owner",),
|
||||
("GET", "/service/api/bot/broadcast/recipients"): ("owner",),
|
||||
("GET", "/service/api/bot/broadcast/history"): ("owner",),
|
||||
("POST", "/service/api/bot/broadcast"): ("owner",),
|
||||
|
||||
# === OTHER API (main.py) ===
|
||||
("GET", "/service/api/shs"): ("owner", "engineer", "technician"),
|
||||
("GET", "/service/api/tasks"): ("owner", "engineer", "technician"),
|
||||
("GET", "/service/api/changelog"): ("authenticated",),
|
||||
|
||||
# === DOCUMENTS ===
|
||||
("GET", "/service/documents/"): ("owner", "engineer", "technician"),
|
||||
("GET", "/service/documents/admin/permissions"): ("owner",),
|
||||
("POST", "/service/documents/admin/permissions"): ("owner",),
|
||||
("POST", "/service/documents/admin/create"): ("owner",),
|
||||
("GET", "/service/documents/tech-access"): ("engineer",),
|
||||
("POST", "/service/documents/tech-access"): ("engineer",),
|
||||
("GET", "/service/documents/{slug}"): ("owner", "engineer", "technician"),
|
||||
("GET", "/service/documents/{slug}/edit"): ("owner", "engineer"),
|
||||
("POST", "/service/documents/{slug}/edit"): ("owner", "engineer"),
|
||||
("GET", "/service/documents/{slug}/download"): ("owner",),
|
||||
}
|
||||
|
||||
# Routes with path parameters — skipped in strict permission test
|
||||
# but still verified to exist in discover test.
|
||||
DYNAMIC_ROUTES: set[tuple[str, str]] = {
|
||||
("POST", "/service/coefficients/test/{formula_name}"),
|
||||
("GET", "/service/questionnaire/step/{step}"),
|
||||
("POST", "/service/questionnaire/save/{step}"),
|
||||
("GET", "/service/documents/{slug}"),
|
||||
("GET", "/service/documents/{slug}/edit"),
|
||||
("POST", "/service/documents/{slug}/edit"),
|
||||
("GET", "/service/documents/{slug}/download"),
|
||||
}
|
||||
|
||||
# Routes excluded from discover test (internal framework routes)
|
||||
EXCLUDED_ROUTES: set[tuple[str, str]] = {
|
||||
("GET", "/docs"),
|
||||
("GET", "/docs/oauth2-redirect"),
|
||||
("GET", "/openapi.json"),
|
||||
("GET", "/redoc"),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Auto-discover all routes and verify they're covered in permission_config.
|
||||
|
||||
This test runs without a server — it inspects the FastAPI app directly.
|
||||
When a new route is added, this test will FAIL until it's added to
|
||||
permission_config.ROUTE_PERMISSIONS.
|
||||
"""
|
||||
|
||||
from tests.permission_config import ROUTE_PERMISSIONS, DYNAMIC_ROUTES, EXCLUDED_ROUTES
|
||||
|
||||
|
||||
def collect_app_routes():
|
||||
"""Return set of (method, path) from the main app and all routers."""
|
||||
from app.main import app
|
||||
routes = set()
|
||||
for route in app.routes:
|
||||
if hasattr(route, "methods") and hasattr(route, "path"):
|
||||
for method in route.methods:
|
||||
method = method.upper()
|
||||
if method in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
||||
routes.add((method, route.path))
|
||||
return routes
|
||||
|
||||
|
||||
def test_all_routes_covered():
|
||||
"""Every app route must have a permission entry."""
|
||||
app_routes = collect_app_routes() - EXCLUDED_ROUTES
|
||||
config_routes = set(ROUTE_PERMISSIONS.keys())
|
||||
missing = app_routes - config_routes
|
||||
assert not missing, (
|
||||
f"Routes missing from permission_config.ROUTE_PERMISSIONS:\n"
|
||||
+ "\n".join(f" {m} {p}" for m, p in sorted(missing))
|
||||
+ "\n\nAdd them to py_service/tests/permission_config.py"
|
||||
)
|
||||
|
||||
|
||||
def test_no_stale_permissions():
|
||||
"""No stale entries in permission config."""
|
||||
app_routes = collect_app_routes() - EXCLUDED_ROUTES
|
||||
config_routes = set(ROUTE_PERMISSIONS.keys())
|
||||
stale = config_routes - app_routes
|
||||
assert not stale, (
|
||||
f"ROUTE_PERMISSIONS has stale entries (route no longer exists):\n"
|
||||
+ "\n".join(f" {m} {p}" for m, p in sorted(stale))
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_routes_also_covered():
|
||||
"""All DYNAMIC_ROUTES must also be in ROUTE_PERMISSIONS."""
|
||||
for entry in DYNAMIC_ROUTES:
|
||||
assert entry in ROUTE_PERMISSIONS, (
|
||||
f"{entry[0]} {entry[1]} is in DYNAMIC_ROUTES but missing from ROUTE_PERMISSIONS"
|
||||
)
|
||||
|
||||
|
||||
def test_all_roles_valid():
|
||||
"""Allowed roles must be one of: owner, engineer, technician, anonymous, authenticated."""
|
||||
valid_roles = {"owner", "engineer", "technician", "anonymous", "authenticated"}
|
||||
for (method, path), allowed_roles in ROUTE_PERMISSIONS.items():
|
||||
for role in allowed_roles:
|
||||
assert role in valid_roles, (
|
||||
f"{method} {path}: invalid role '{role}' (valid: {valid_roles})"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Strict permission tests — every role × every route with exact expected codes.
|
||||
|
||||
ALLOWED → status must be 200 or 302 (never 403/404/500)
|
||||
DENIED → status must be 403 (strict — not 302!)
|
||||
ANONYMOUS → protected routes get 302 (redirect to login)
|
||||
→ public routes get 200
|
||||
|
||||
Dynamic routes (with path params like {slug}) are skipped here.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from tests.permission_config import ROUTE_PERMISSIONS, DYNAMIC_ROUTES, ROLES
|
||||
|
||||
|
||||
def _generate_test_cases():
|
||||
"""Yield (role, method, path, expected) tuples."""
|
||||
for (method, path), allowed_roles in ROUTE_PERMISSIONS.items():
|
||||
if (method, path) in DYNAMIC_ROUTES:
|
||||
continue
|
||||
for role in ROLES:
|
||||
if role in allowed_roles or "authenticated" in allowed_roles:
|
||||
yield (role, method, path, "allow")
|
||||
else:
|
||||
yield (role, method, path, "deny")
|
||||
|
||||
# Anonymous
|
||||
if "anonymous" in allowed_roles:
|
||||
yield ("anonymous", method, path, "allow")
|
||||
else:
|
||||
yield ("anonymous", method, path, "deny")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role,method,path,expected", list(_generate_test_cases()))
|
||||
def test_access(role_client, role, method, path, expected):
|
||||
client = role_client[role]
|
||||
resp = client.request(method, path)
|
||||
|
||||
if expected == "allow":
|
||||
assert resp.status_code not in (403, 404), (
|
||||
f"[{role}] {method} {path}: got {resp.status_code}, expected non-403/404"
|
||||
)
|
||||
if resp.status_code == 500:
|
||||
pytest.fail(f"[{role}] {method} {path}: 500 Internal Server Error")
|
||||
else:
|
||||
if role == "anonymous":
|
||||
assert resp.status_code in (302, 403), (
|
||||
f"[{role}] {method} {path}: got {resp.status_code}, expected 302/403"
|
||||
)
|
||||
else:
|
||||
assert resp.status_code == 403, (
|
||||
f"[{role}] {method} {path}: got {resp.status_code}, expected 403"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Test role switching lifecycle — owner→engineer→reset, etc.
|
||||
|
||||
These tests verify that the session remains consistent after role switches.
|
||||
The critical check: after a reset, the original role's access is fully restored.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from tests.conftest import SERVER_URL, PASSWORD
|
||||
|
||||
|
||||
class TestRoleSwitch:
|
||||
|
||||
def _login(self, role: str) -> httpx.Client:
|
||||
"""Create a fresh authenticated client."""
|
||||
c = httpx.Client(base_url=SERVER_URL, follow_redirects=False, timeout=10)
|
||||
resp = c.post("/service/login", data={"login": role, "password": PASSWORD})
|
||||
assert resp.status_code == 302
|
||||
return c
|
||||
|
||||
def _switch_role(self, client: httpx.Client, target: str):
|
||||
resp = client.post("/service/api/role/switch", json={"role": target})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
return data
|
||||
|
||||
def _reset_role(self, client: httpx.Client):
|
||||
resp = client.post("/service/api/role/reset")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
return data
|
||||
|
||||
def test_owner_to_engineer_and_back(self):
|
||||
"""Owner switches to engineer, then resets — full access restored."""
|
||||
client = self._login("owner")
|
||||
|
||||
# Owner can access owner pages
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Switch to engineer
|
||||
data = self._switch_role(client, "engineer")
|
||||
assert data["role"] == "engineer"
|
||||
assert data["original_role"] == "owner"
|
||||
|
||||
# Engineer cannot access owner pages
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 403, "Engineer should be denied owner pages"
|
||||
|
||||
# Engineer CAN access shared pages
|
||||
r = client.get("/service/customers")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset to owner
|
||||
data = self._reset_role(client)
|
||||
assert data["role"] == "owner"
|
||||
|
||||
# CRITICAL: Owner access restored
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 200, "Owner access not restored after reset!"
|
||||
|
||||
# Dashboard still works
|
||||
r = client.get("/service/dashboard")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_owner_to_technician_and_back(self):
|
||||
"""Owner switches directly to technician."""
|
||||
client = self._login("owner")
|
||||
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
self._switch_role(client, "technician")
|
||||
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 403
|
||||
|
||||
r = client.get("/service/tasks")
|
||||
assert r.status_code == 200
|
||||
|
||||
self._reset_role(client)
|
||||
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_engineer_to_technician_and_back(self):
|
||||
"""Engineer switches to technician, then resets."""
|
||||
client = self._login("engineer")
|
||||
|
||||
# Engineer can access engineer pages
|
||||
r = client.get("/service/customers")
|
||||
assert r.status_code == 200
|
||||
|
||||
self._switch_role(client, "technician")
|
||||
|
||||
# Technician cannot access engineer pages
|
||||
r = client.get("/service/customers")
|
||||
assert r.status_code == 403
|
||||
|
||||
r = client.get("/service/checklist")
|
||||
assert r.status_code == 200
|
||||
|
||||
self._reset_role(client)
|
||||
|
||||
# Engineer access restored
|
||||
r = client.get("/service/customers")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_double_switch_owner_engineer_technician_then_reset(self):
|
||||
"""Multi-step: owner→engineer→technician→reset→owner."""
|
||||
client = self._login("owner")
|
||||
|
||||
self._switch_role(client, "engineer")
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 403
|
||||
|
||||
# Switch deeper while already impersonating
|
||||
self._switch_role(client, "technician")
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 403
|
||||
r = client.get("/service/tasks")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Single reset should restore owner
|
||||
self._reset_role(client)
|
||||
r = client.get("/service/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_forbidden_switch_technician_cannot_switch(self):
|
||||
"""Technician cannot use role switch (not in allowed_roles)."""
|
||||
client = self._login("technician")
|
||||
resp = client.post("/service/api/role/switch", json={"role": "owner"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_reset_without_switch_does_not_error(self):
|
||||
"""Reset without an active override should still work."""
|
||||
client = self._login("owner")
|
||||
resp = client.post("/service/api/role/reset")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "owner"
|
||||
@@ -0,0 +1,175 @@
|
||||
"""CRUD operation tests — create, edit, delete for all entities.
|
||||
|
||||
Each test creates a unique entity, verifies the operation returns a redirect,
|
||||
and cleans up where possible. No HTML parsing — just HTTP status checks.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
class TestUserCRUD:
|
||||
ID = None
|
||||
|
||||
def test_create_user(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
resp = owner_client.post("/service/users/create", data={
|
||||
"login": f"test_user_{ts}",
|
||||
"password": "TestPass123!",
|
||||
"full_name": f"Test User {ts}",
|
||||
"role": "technician",
|
||||
"phone": "+7-123-456-7890",
|
||||
"email": f"test_{ts}@example.com",
|
||||
"is_active": "1",
|
||||
})
|
||||
assert resp.status_code == 302, f"Create user failed: {resp.status_code}"
|
||||
# Follow redirect should succeed
|
||||
assert resp.headers.get("location") == "/service/users"
|
||||
|
||||
def test_edit_user(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
# First create a user to edit
|
||||
resp = owner_client.post("/service/users/create", data={
|
||||
"login": f"edit_user_{ts}",
|
||||
"password": "TestPass123!",
|
||||
"full_name": f"Edit User {ts}",
|
||||
"role": "technician",
|
||||
"is_active": "1",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers.get("location", "")
|
||||
# Extract ID from redirect or use default — the redirect goes to /service/users
|
||||
# We need the user ID. We'll need to find it from the list page.
|
||||
# For now, edit by fetching the users page and extracting an ID
|
||||
resp = owner_client.get("/service/users")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
import re
|
||||
# Find our user in the HTML to get their ID
|
||||
# Pattern: <tr data-id="(\d+)">.*edit_user_{ts}
|
||||
match = re.search(rf"data-id=['\"](\d+)['\"][^>]*>.*?edit_user_{ts}", body, re.DOTALL)
|
||||
if not match:
|
||||
# Try alternative pattern: look for the login in a more flexible way
|
||||
match = re.search(rf"edit_user_{ts}.*?data-id=['\"](\d+)['\"]", body, re.DOTALL)
|
||||
if match:
|
||||
user_id = match.group(1)
|
||||
else:
|
||||
# Fallback: find any user ID with role technician
|
||||
match = re.search(r"data-id=['\"](\d+)['\"]", body)
|
||||
if not match:
|
||||
pytest.skip("Could not find a user to edit")
|
||||
user_id = match.group(1)
|
||||
|
||||
resp = owner_client.post("/service/users/edit", data={
|
||||
"id": user_id,
|
||||
"full_name": f"Updated User {ts}",
|
||||
"role": "technician",
|
||||
"phone": "+7-999-888-7766",
|
||||
"email": f"updated_{ts}@example.com",
|
||||
"is_active": "1",
|
||||
})
|
||||
assert resp.status_code == 302, f"Edit user returned {resp.status_code}"
|
||||
|
||||
def test_delete_user(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
# Create user to delete
|
||||
resp = owner_client.post("/service/users/create", data={
|
||||
"login": f"del_user_{ts}",
|
||||
"password": "TestPass123!",
|
||||
"full_name": f"Delete User {ts}",
|
||||
"role": "technician",
|
||||
"is_active": "1",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
# Find user ID
|
||||
resp = owner_client.get("/service/users")
|
||||
body = resp.text
|
||||
import re
|
||||
match = re.search(rf"data-id=['\"](\d+)['\"][^>]*>.*?del_user_{ts}", body, re.DOTALL)
|
||||
if not match:
|
||||
match = re.search(rf"del_user_{ts}.*?data-id=['\"](\d+)['\"]", body, re.DOTALL)
|
||||
if match:
|
||||
user_id = match.group(1)
|
||||
resp = owner_client.post("/service/users/delete", data={"id": user_id})
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestCustomerCRUD:
|
||||
|
||||
def test_create_customer(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
inn = str(1000000000 + (ts % 8999999999))
|
||||
resp = owner_client.post("/service/customers/create", data={
|
||||
"name": f"Test Customer {ts}",
|
||||
"inn": inn[:10],
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
def test_edit_customer(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
inn = str(1000000000 + (ts % 8999999999))
|
||||
resp = owner_client.post("/service/customers/create", data={
|
||||
"name": f"Edit Customer {ts}",
|
||||
"inn": inn[:10],
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
resp = owner_client.get("/service/customers")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
import re
|
||||
match = re.search(rf"data-id=['\"](\d+)['\"]", body)
|
||||
if match:
|
||||
cid = match.group(1)
|
||||
resp = owner_client.post("/service/customers/edit", data={
|
||||
"id": cid,
|
||||
"name": f"Updated Customer {ts}",
|
||||
"inn": inn[:10],
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestBlogCRUD:
|
||||
|
||||
def test_create_blog_post(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
resp = owner_client.post("/service/blog/create", data={
|
||||
"title": f"Test Blog Post {ts}",
|
||||
"content": f"Content for test blog post {ts}",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
def test_edit_blog_post(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
resp = owner_client.post("/service/blog/create", data={
|
||||
"title": f"Edit Blog Post {ts}",
|
||||
"content": f"Content {ts}",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
resp = owner_client.get("/service/blog")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
import re
|
||||
match = re.search(rf"Edit Blog Post {ts}.*?data-id=['\"](\d+)['\"]", body, re.DOTALL)
|
||||
if match:
|
||||
post_id = match.group(1)
|
||||
resp = owner_client.post("/service/blog/edit", data={
|
||||
"id": post_id,
|
||||
"title": f"Updated Blog Post {ts}",
|
||||
"content": f"Updated content {ts}",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestQuestionnaireConfigCRUD:
|
||||
|
||||
def test_create_questionnaire_config(self, owner_client):
|
||||
ts = int(time.time() * 1000)
|
||||
resp = owner_client.post("/service/questionnaire-config/create", data={
|
||||
"key": f"test_key_{ts}",
|
||||
"label": f"Test Label {ts}",
|
||||
"question_text": f"Test question? {ts}",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Detect 500 Internal Server Errors — test POST routes with edge-case inputs.
|
||||
|
||||
These tests send malformed or empty data to ensure routes handle errors
|
||||
gracefully (redirect or 400/422) instead of crashing with 500.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MALFORMED_POST_CASES = [
|
||||
# USERS
|
||||
("POST", "/service/users/create", {}),
|
||||
("POST", "/service/users/create", {"login": "no_password"}),
|
||||
("POST", "/service/users/create", {"password": "no_login"}),
|
||||
("POST", "/service/users/edit", {}),
|
||||
("POST", "/service/users/edit", {"id": ""}),
|
||||
("POST", "/service/users/edit", {"id": "not_a_number"}),
|
||||
("POST", "/service/users/delete", {}),
|
||||
("POST", "/service/users/delete", {"id": ""}),
|
||||
|
||||
# CUSTOMERS
|
||||
("POST", "/service/customers/create", {}),
|
||||
("POST", "/service/customers/create", {"name": "no_inn"}),
|
||||
("POST", "/service/customers/create", {"inn": "bad_inn"}),
|
||||
("POST", "/service/customers/edit", {}),
|
||||
("POST", "/service/customers/delete", {}),
|
||||
|
||||
# OBJECTS
|
||||
("POST", "/service/objects/create", {}),
|
||||
("POST", "/service/objects/edit", {}),
|
||||
("POST", "/service/objects/delete", {}),
|
||||
|
||||
# ASSIGNMENTS
|
||||
("POST", "/service/assignments/create", {}),
|
||||
("POST", "/service/assignments/unassign", {}),
|
||||
|
||||
# SLA
|
||||
("POST", "/service/sla/create", {}),
|
||||
("POST", "/service/sla/edit", {}),
|
||||
|
||||
# CEO
|
||||
("POST", "/service/ceo/calc", {}),
|
||||
("POST", "/service/ceo/kpi", {}),
|
||||
|
||||
# COEFFICIENTS
|
||||
("POST", "/service/coefficients/edit", {}),
|
||||
("POST", "/service/coefficients/toggle", {}),
|
||||
("POST", "/service/coefficients/create", {}),
|
||||
("POST", "/service/coefficients/delete", {}),
|
||||
|
||||
# TASKS
|
||||
("POST", "/service/tasks/create", {}),
|
||||
("POST", "/service/tasks/close", {}),
|
||||
|
||||
# REPORTS
|
||||
("POST", "/service/reports/create", {}),
|
||||
|
||||
# INCIDENTS
|
||||
("POST", "/service/incidents/create", {}),
|
||||
("POST", "/service/incidents/resolve", {}),
|
||||
|
||||
# BLOG
|
||||
("POST", "/service/blog/create", {}),
|
||||
("POST", "/service/blog/edit", {}),
|
||||
("POST", "/service/blog/delete", {}),
|
||||
|
||||
# IDEAS
|
||||
("POST", "/service/ideas/create", {}),
|
||||
("POST", "/service/ideas/edit", {}),
|
||||
("POST", "/service/ideas/status", {}),
|
||||
("POST", "/service/ideas/delete", {}),
|
||||
|
||||
# CASES
|
||||
("POST", "/service/cases/create", {}),
|
||||
("POST", "/service/cases/edit", {}),
|
||||
("POST", "/service/cases/delete", {}),
|
||||
|
||||
# QUESTIONNAIRE CONFIG
|
||||
("POST", "/service/questionnaire-config/create", {}),
|
||||
("POST", "/service/questionnaire-config/edit", {}),
|
||||
("POST", "/service/questionnaire-config/toggle", {}),
|
||||
("POST", "/service/questionnaire-config/delete", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path,data", MALFORMED_POST_CASES)
|
||||
def test_no_500_on_malformed_input(owner_client, method, path, data):
|
||||
"""Malformed input should NOT cause a 500."""
|
||||
resp = owner_client.request(method, path, data=data)
|
||||
assert resp.status_code != 500, (
|
||||
f"{method} {path} with {data}: got 500 Internal Server Error"
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Test that all service portal routes return appropriate status codes.
|
||||
This helps catch 404 (missing routes), 500 (server errors), and auth issues.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Routes accessible without authentication (should return 200 or 302 redirect)
|
||||
PUBLIC_ROUTES = [
|
||||
("GET", "/service/login"),
|
||||
("GET", "/health"),
|
||||
]
|
||||
|
||||
# All protected GET routes (require auth)
|
||||
PROTECTED_GET_ROUTES = [
|
||||
"/service/dashboard",
|
||||
"/service/users",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/assignments",
|
||||
"/service/sla",
|
||||
"/service/ceo",
|
||||
"/service/coefficients",
|
||||
"/service/coefficients/test",
|
||||
"/service/formulas",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/questionnaire",
|
||||
"/service/questionnaire-config",
|
||||
"/service/passports",
|
||||
"/service/checklist",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/ideas",
|
||||
"/service/charts",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/bot-settings",
|
||||
"/service/bot-settings/responses",
|
||||
"/service/bot-settings/features",
|
||||
"/service/bot-settings/categories",
|
||||
"/service/bot-settings/kb",
|
||||
"/service/bot-settings/conversations",
|
||||
"/service/bot-settings/users",
|
||||
"/service/bot-settings/test-runner",
|
||||
"/service/bot-settings/analytics",
|
||||
"/service/quick-menu",
|
||||
"/service/documents/",
|
||||
]
|
||||
|
||||
API_GET_ROUTES = [
|
||||
"/service/api/shs",
|
||||
"/service/api/tasks",
|
||||
"/service/api/changelog",
|
||||
"/service/api/quick-menu",
|
||||
"/service/api/role-permissions",
|
||||
"/service/api/charts/data",
|
||||
"/service/api/bot/settings",
|
||||
"/service/api/bot/templates",
|
||||
"/service/api/bot/features",
|
||||
"/service/api/bot/categories",
|
||||
"/service/api/bot/kb",
|
||||
"/service/api/bot/conversations",
|
||||
"/service/api/bot/users",
|
||||
"/service/api/bot/analytics",
|
||||
"/service/api/bot/broadcast/history",
|
||||
"/service/api/bot/broadcast/recipients",
|
||||
]
|
||||
|
||||
|
||||
class TestPublicRoutes:
|
||||
def test_login_page(self, client):
|
||||
resp = client.get("/service/login")
|
||||
assert resp.status_code in (200, 302)
|
||||
|
||||
def test_health(self, client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_root_redirect(self, client):
|
||||
resp = client.get("/", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestAuthRedirect:
|
||||
"""Unauthenticated requests should redirect to login."""
|
||||
|
||||
@pytest.mark.parametrize("method,path", PUBLIC_ROUTES)
|
||||
def test_public(self, client, method, path):
|
||||
resp = getattr(client, method.lower())(path, follow_redirects=False)
|
||||
assert resp.status_code in (200, 302), f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
@pytest.mark.parametrize("path", PROTECTED_GET_ROUTES)
|
||||
def test_protected_redirect(self, client, path):
|
||||
resp = client.get(path, follow_redirects=False)
|
||||
assert resp.status_code == 302, f"GET {path} returned {resp.status_code} (expected 302)"
|
||||
|
||||
@pytest.mark.parametrize("path", API_GET_ROUTES)
|
||||
def test_api_no_auth(self, client, path):
|
||||
"""API routes bypass auth middleware, but may fail due to missing DB."""
|
||||
resp = client.get(path)
|
||||
# Should not be 404 — either 200, 500 (DB error), or 403
|
||||
assert resp.status_code != 404, f"GET {path} returned 404 (route missing)"
|
||||
|
||||
|
||||
class TestOwnerAccess:
|
||||
"""Owner should have access to all pages."""
|
||||
|
||||
@pytest.mark.parametrize("path", PROTECTED_GET_ROUTES)
|
||||
def test_all_pages(self, owner_client, path):
|
||||
resp = owner_client.get(path, follow_redirects=False)
|
||||
# 200 = success, 302 = redirect (some pages redirect), 500 = server error
|
||||
assert resp.status_code in (200, 302, 500), f"GET {path} returned {resp.status_code}"
|
||||
if resp.status_code == 500:
|
||||
pytest.fail(f"GET {path} returned 500 Internal Server Error")
|
||||
|
||||
@pytest.mark.parametrize("path", API_GET_ROUTES)
|
||||
def test_api_routes(self, owner_client, path):
|
||||
resp = owner_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code != 404, f"GET {path} returned 404 (route missing)"
|
||||
|
||||
|
||||
class TestEngineerAccess:
|
||||
"""Engineer should have access to permitted pages only."""
|
||||
|
||||
ENGINEER_ALLOWED = [
|
||||
"/service/dashboard",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/sla",
|
||||
"/service/questionnaire",
|
||||
"/service/passports",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/documents/",
|
||||
]
|
||||
|
||||
ENGINEER_FORBIDDEN = [
|
||||
"/service/users",
|
||||
"/service/assignments",
|
||||
"/service/ceo",
|
||||
"/service/coefficients",
|
||||
"/service/coefficients/test",
|
||||
"/service/formulas",
|
||||
"/service/questionnaire-config",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/ideas",
|
||||
"/service/charts",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/bot-settings",
|
||||
"/service/bot-settings/responses",
|
||||
"/service/bot-settings/features",
|
||||
"/service/bot-settings/categories",
|
||||
"/service/bot-settings/kb",
|
||||
"/service/bot-settings/users",
|
||||
"/service/bot-settings/test-runner",
|
||||
"/service/bot-settings/analytics",
|
||||
"/service/bot-settings/conversations",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("path", ENGINEER_ALLOWED)
|
||||
def test_allowed(self, engineer_client, path):
|
||||
resp = engineer_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code in (200, 302, 500), f"GET {path} returned {resp.status_code}"
|
||||
if resp.status_code == 500:
|
||||
pytest.fail(f"GET {path} returned 500 Internal Server Error")
|
||||
|
||||
@pytest.mark.parametrize("path", ENGINEER_FORBIDDEN)
|
||||
def test_forbidden(self, engineer_client, path):
|
||||
resp = engineer_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code in (302, 403), f"GET {path} returned {resp.status_code} (expected 302/403)"
|
||||
|
||||
|
||||
class TestTechnicianAccess:
|
||||
"""Technician should have very limited access."""
|
||||
|
||||
TECHNICIAN_ALLOWED = [
|
||||
"/service/dashboard",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/checklist",
|
||||
"/service/documents/",
|
||||
]
|
||||
|
||||
TECHNICIAN_FORBIDDEN = [
|
||||
"/service/users",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/assignments",
|
||||
"/service/sla",
|
||||
"/service/ceo",
|
||||
"/service/coefficients",
|
||||
"/service/formulas",
|
||||
"/service/questionnaire",
|
||||
"/service/questionnaire-config",
|
||||
"/service/passports",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/ideas",
|
||||
"/service/charts",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/bot-settings",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("path", TECHNICIAN_ALLOWED)
|
||||
def test_allowed(self, technician_client, path):
|
||||
resp = technician_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code in (200, 302, 500), f"GET {path} returned {resp.status_code}"
|
||||
|
||||
@pytest.mark.parametrize("path", TECHNICIAN_FORBIDDEN)
|
||||
def test_forbidden(self, technician_client, path):
|
||||
resp = technician_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code in (302, 403), f"GET {path} returned {resp.status_code} (expected 302/403)"
|
||||
|
||||
|
||||
class TestSidebarRoutes:
|
||||
"""Every sidebar link should resolve without 404."""
|
||||
|
||||
SIDEBAR_LINKS = [
|
||||
"/service/dashboard",
|
||||
"/service/charts",
|
||||
"/service/ceo",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/sla",
|
||||
"/service/questionnaire",
|
||||
"/service/passports",
|
||||
"/service/users",
|
||||
"/service/assignments",
|
||||
"/service/documents/",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/checklist",
|
||||
"/service/documents/tech-access",
|
||||
"/service/bot-settings/analytics",
|
||||
"/service/bot-settings/users",
|
||||
"/service/bot-settings/conversations",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/questionnaire-config",
|
||||
"/service/formulas",
|
||||
"/service/bot-settings",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/ideas",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("path", SIDEBAR_LINKS)
|
||||
def test_sidebar_link(self, owner_client, path):
|
||||
resp = owner_client.get(path, follow_redirects=False)
|
||||
assert resp.status_code != 404, f"Sidebar link {path} returned 404 (missing route)"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Test that all expected routes are defined in the service_pages router.
|
||||
This catches missing routes (potential 404s) purely from code inspection.
|
||||
No database or server is needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.routers.service_pages import router
|
||||
|
||||
|
||||
def get_registered_routes():
|
||||
"""Return set of (method, path) tuples from the service_pages router."""
|
||||
routes = set()
|
||||
for route in router.routes:
|
||||
if hasattr(route, "methods") and hasattr(route, "path"):
|
||||
for method in route.methods:
|
||||
routes.add((method, route.path))
|
||||
return routes
|
||||
|
||||
|
||||
# All routes that SHOULD be registered in service_pages.py
|
||||
EXPECTED_ROUTES = [
|
||||
("GET", "/service/users"),
|
||||
("POST", "/service/users/create"),
|
||||
("POST", "/service/users/edit"),
|
||||
("POST", "/service/users/delete"),
|
||||
("GET", "/service/customers"),
|
||||
("POST", "/service/customers/create"),
|
||||
("POST", "/service/customers/edit"),
|
||||
("POST", "/service/customers/delete"),
|
||||
("GET", "/service/objects"),
|
||||
("POST", "/service/objects/create"),
|
||||
("POST", "/service/objects/edit"),
|
||||
("POST", "/service/objects/delete"),
|
||||
("GET", "/service/assignments"),
|
||||
("POST", "/service/assignments/create"),
|
||||
("POST", "/service/assignments/unassign"),
|
||||
("GET", "/service/sla"),
|
||||
("POST", "/service/sla/create"),
|
||||
("POST", "/service/sla/edit"),
|
||||
("GET", "/service/ceo"),
|
||||
("POST", "/service/ceo/calc"),
|
||||
("POST", "/service/ceo/kpi"),
|
||||
("GET", "/service/coefficients"),
|
||||
("POST", "/service/coefficients/edit"),
|
||||
("POST", "/service/coefficients/toggle"),
|
||||
("POST", "/service/coefficients/create"),
|
||||
("POST", "/service/coefficients/delete"),
|
||||
("GET", "/service/coefficients/test"),
|
||||
("GET", "/service/formulas"),
|
||||
("GET", "/service/tasks"),
|
||||
("POST", "/service/tasks/create"),
|
||||
("POST", "/service/tasks/close"),
|
||||
("GET", "/service/reports"),
|
||||
("POST", "/service/reports/create"),
|
||||
("GET", "/service/incidents"),
|
||||
("POST", "/service/incidents/create"),
|
||||
("POST", "/service/incidents/resolve"),
|
||||
("GET", "/service/questionnaire"),
|
||||
("GET", "/service/questionnaire/step/{step}"),
|
||||
("POST", "/service/questionnaire/save/{step}"),
|
||||
("GET", "/service/questionnaire-config"),
|
||||
("POST", "/service/questionnaire-config/create"),
|
||||
("POST", "/service/questionnaire-config/edit"),
|
||||
("POST", "/service/questionnaire-config/toggle"),
|
||||
("POST", "/service/questionnaire-config/delete"),
|
||||
("GET", "/service/passports"),
|
||||
("GET", "/service/checklist"),
|
||||
("POST", "/service/checklist/complete"),
|
||||
("GET", "/service/blog"),
|
||||
("POST", "/service/blog/create"),
|
||||
("POST", "/service/blog/edit"),
|
||||
("POST", "/service/blog/delete"),
|
||||
("GET", "/service/api/quick-menu"),
|
||||
("GET", "/service/quick-menu"),
|
||||
("POST", "/service/quick-menu/save"),
|
||||
("GET", "/service/quick-menu/tech"),
|
||||
("POST", "/service/quick-menu/tech-save"),
|
||||
("GET", "/service/ideas"),
|
||||
("POST", "/service/ideas/create"),
|
||||
("POST", "/service/ideas/edit"),
|
||||
("POST", "/service/ideas/status"),
|
||||
("POST", "/service/ideas/delete"),
|
||||
("GET", "/service/charts"),
|
||||
("GET", "/service/api/charts/data"),
|
||||
("GET", "/service/cases"),
|
||||
("POST", "/service/cases/create"),
|
||||
("POST", "/service/cases/edit"),
|
||||
("POST", "/service/cases/delete"),
|
||||
("GET", "/service/bot-settings"),
|
||||
("GET", "/service/bot-settings/responses"),
|
||||
("GET", "/service/bot-settings/features"),
|
||||
("GET", "/service/bot-settings/categories"),
|
||||
("GET", "/service/bot-settings/kb"),
|
||||
("GET", "/service/bot-settings/conversations"),
|
||||
("GET", "/service/bot-settings/users"),
|
||||
("GET", "/service/bot-settings/test-runner"),
|
||||
("GET", "/service/bot-settings/analytics"),
|
||||
("GET", "/service/portal-settings"),
|
||||
("GET", "/service/role-settings"),
|
||||
("GET", "/service/api/role-permissions"),
|
||||
("POST", "/service/api/role-permissions/save"),
|
||||
("POST", "/service/api/role/switch"),
|
||||
("POST", "/service/api/role/reset"),
|
||||
("GET", "/service/api/bot/settings"),
|
||||
("POST", "/service/api/bot/settings/save"),
|
||||
("GET", "/service/api/bot/templates"),
|
||||
("POST", "/service/api/bot/templates/save"),
|
||||
("GET", "/service/api/bot/features"),
|
||||
("POST", "/service/api/bot/features/toggle"),
|
||||
("GET", "/service/api/bot/categories"),
|
||||
("POST", "/service/api/bot/categories/create"),
|
||||
("POST", "/service/api/bot/categories/edit"),
|
||||
("POST", "/service/api/bot/categories/delete"),
|
||||
("GET", "/service/api/bot/kb"),
|
||||
("POST", "/service/api/bot/kb/create"),
|
||||
("POST", "/service/api/bot/kb/edit"),
|
||||
("POST", "/service/api/bot/kb/delete"),
|
||||
("GET", "/service/api/bot/conversations"),
|
||||
("GET", "/service/api/bot/users"),
|
||||
("GET", "/service/api/bot/analytics"),
|
||||
("POST", "/service/api/bot/test-run"),
|
||||
("GET", "/service/api/bot/broadcast/recipients"),
|
||||
("GET", "/service/api/bot/broadcast/history"),
|
||||
("POST", "/service/api/bot/broadcast"),
|
||||
]
|
||||
|
||||
|
||||
class TestRoutesDefined:
|
||||
def test_routes_exist(self):
|
||||
actual = get_registered_routes()
|
||||
missing = []
|
||||
for method, path in EXPECTED_ROUTES:
|
||||
if (method, path) not in actual:
|
||||
missing.append(f"{method} {path}")
|
||||
assert not missing, f"Missing routes:\n" + "\n".join(missing)
|
||||
|
||||
def test_no_unexpected_missing_links(self):
|
||||
"""Every sidebar link should have a corresponding route."""
|
||||
from app.main import app as main_app
|
||||
# Collect all GET routes from both main app and service_pages router
|
||||
all_destinations = set()
|
||||
for route in list(router.routes) + list(main_app.routes):
|
||||
if hasattr(route, "methods") and hasattr(route, "path") and "GET" in route.methods:
|
||||
all_destinations.add(route.path)
|
||||
sidebar_links = [
|
||||
"/service/dashboard",
|
||||
"/service/charts",
|
||||
"/service/ceo",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/sla",
|
||||
"/service/questionnaire",
|
||||
"/service/passports",
|
||||
"/service/users",
|
||||
"/service/assignments",
|
||||
"/service/documents/",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/checklist",
|
||||
"/service/documents/tech-access",
|
||||
"/service/bot-settings/analytics",
|
||||
"/service/bot-settings/users",
|
||||
"/service/bot-settings/conversations",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/questionnaire-config",
|
||||
"/service/formulas",
|
||||
"/service/bot-settings",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/ideas",
|
||||
]
|
||||
for link in sidebar_links:
|
||||
assert link in all_destinations, f"Sidebar link {link} has no GET route registered"
|
||||
|
||||
|
||||
class TestCoefficientsTestCalc:
|
||||
"""The formula test POST route uses path parameter {formula_name}."""
|
||||
|
||||
def test_formula_test_route_pattern(self):
|
||||
actual = get_registered_routes()
|
||||
assert ("POST", "/service/coefficients/test/{formula_name}") in actual, \
|
||||
"Missing POST route pattern for formula tests"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Smoke test — проверяет что сервер отвечает на все основные маршруты.
|
||||
Запуск: TEST_SERVER_URL=http://localhost:8000 pytest tests/test_smoke_server.py -v
|
||||
|
||||
Работает против запущенного сервера (локально или удалённо).
|
||||
Не требует БД — просто проверяет что ответ не 404 и не 502.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
# All GET routes from the sidebar + common pages
|
||||
ROUTES_TO_CHECK = [
|
||||
"/service/dashboard",
|
||||
"/service/charts",
|
||||
"/service/ceo",
|
||||
"/service/customers",
|
||||
"/service/objects",
|
||||
"/service/sla",
|
||||
"/service/questionnaire",
|
||||
"/service/passports",
|
||||
"/service/users",
|
||||
"/service/assignments",
|
||||
"/service/tasks",
|
||||
"/service/reports",
|
||||
"/service/incidents",
|
||||
"/service/checklist",
|
||||
"/service/documents/",
|
||||
"/service/documents/tech-access",
|
||||
"/service/blog",
|
||||
"/service/cases",
|
||||
"/service/ideas",
|
||||
"/service/questionnaire-config",
|
||||
"/service/formulas",
|
||||
"/service/coefficients",
|
||||
"/service/coefficients/test",
|
||||
"/service/bot-settings",
|
||||
"/service/bot-settings/responses",
|
||||
"/service/bot-settings/features",
|
||||
"/service/bot-settings/categories",
|
||||
"/service/bot-settings/kb",
|
||||
"/service/bot-settings/conversations",
|
||||
"/service/bot-settings/users",
|
||||
"/service/bot-settings/test-runner",
|
||||
"/service/bot-settings/analytics",
|
||||
"/service/portal-settings",
|
||||
"/service/role-settings",
|
||||
"/service/quick-menu",
|
||||
]
|
||||
|
||||
# API routes (should work with or without auth)
|
||||
API_ROUTES = [
|
||||
"/service/api/shs",
|
||||
"/service/api/changelog",
|
||||
"/service/api/quick-menu",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with httpx.Client(base_url=SERVER_URL, follow_redirects=False, timeout=10) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestServerReachable:
|
||||
def test_health(self, client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_login_page(self, client):
|
||||
resp = client.get("/service/login")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_root_redirect(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200 or resp.status_code == 302
|
||||
|
||||
|
||||
class TestAuthPagesRedirect:
|
||||
"""Without auth, should redirect to login (302) or show login page (200)."""
|
||||
|
||||
@pytest.mark.parametrize("path", ROUTES_TO_CHECK)
|
||||
def test_redirect_to_login(self, client, path):
|
||||
resp = client.get(path, follow_redirects=False)
|
||||
assert resp.status_code in (200, 302, 403), \
|
||||
f"GET {path}: got {resp.status_code} (expected 302 redirect or 200/403)"
|
||||
|
||||
|
||||
class TestApiRoutes:
|
||||
"""API routes bypass auth middleware — should not 404."""
|
||||
|
||||
@pytest.mark.parametrize("path", API_ROUTES)
|
||||
def test_api_not_404(self, client, path):
|
||||
resp = client.get(path)
|
||||
assert resp.status_code != 404, f"API {path} returned 404 (route missing)"
|
||||
Reference in New Issue
Block a user