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:
2026-05-22 20:45:27 +03:00
parent 64991f0228
commit bd048ea23d
18 changed files with 1989 additions and 10 deletions
View File
+71
View File
@@ -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,
}
+218
View File
@@ -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"
+175
View File
@@ -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"
)
+258
View File
@@ -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)"
+184
View File
@@ -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"
+97
View File
@@ -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)"