"""Тесты безопасности: аутентификация, права доступа, изоляция данных, токены, загрузка файлов, инъекции. Тесты с пометкой xfail фиксируют ИЗВЕСТНЫЕ недостатки (см. docs/SECURITY-REVIEW.md): когда недостаток устранят, тест начнёт проходить (XPASS) — тогда пометку нужно снять. """ import os import tempfile import time from datetime import datetime, timedelta, timezone import pytest os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "s.db").replace("\\", "/")) os.environ.setdefault("SCHEDULER_INTERVAL", "3600") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef") os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) import jwt # noqa: E402 from fastapi.routing import APIRoute # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from app import config # noqa: E402 from app.main import app # noqa: E402 from app.routers import catalog # noqa: E402 V1 = "/api/v1" PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 64 def bearer(t): return {"Authorization": "Bearer " + t} @pytest.fixture(autouse=True) def _reset_limiter(): catalog._fails.clear() yield catalog._fails.clear() @pytest.fixture(scope="module") def env(): """Один набор пользователей/данных на модуль: админ, модератор, инженер, два техника (на смене), выходной техник.""" with TestClient(app) as c: def login(u, p): r = c.post(f"{V1}/auth/login", json={"username": u, "password": p}) assert r.status_code == 200, r.text return r.json() adm = bearer(login("admin", "admin")["access_token"]) always = c.post(f"{V1}/schedules", headers=adm, json={"name": "sec-always", "kind": "weekly", "weekdays": [0, 1, 2, 3, 4, 5, 6], "start_time": "00:00:00", "end_time": "23:59:00"}).json() tech = c.post(f"{V1}/positions", headers=adm, json={"name": "sec-tech", "kind": "technician", "schedule_id": always["id"]}).json() eng = c.post(f"{V1}/positions", headers=adm, json={"name": "sec-eng", "kind": "engineer"}).json() ids = {} for name, pos, extra in (("sec_a", tech, {}), ("sec_b", tech, {}), ("sec_eng", eng, {}), ("sec_mod", None, {"is_moderator": True})): r = c.post(f"{V1}/users", headers=adm, json={"username": name, "password": "Passw0rd!x", "position_id": pos["id"] if pos else None, **extra}) assert r.status_code == 201, r.text ids[name] = r.json()["id"] h = {n: bearer(login(n, "Passw0rd!x")["access_token"]) for n in ids} h["admin"] = adm eng_h = h["sec_eng"] loc = c.post(f"{V1}/locations", headers=eng_h, json={"name": "sec-loc"}).json() eq = c.post(f"{V1}/equipment", headers=eng_h, json={"name": "sec-eq", "location_id": loc["id"]}).json() cl = c.post(f"{V1}/checklists", headers=eng_h, json={"name": "sec-cl", "items": [ {"text": "Осмотр", "answer_type": "status", "photo": "optional"}, {"text": "Давление", "answer_type": "number", "min_value": 1, "max_value": 5}]}).json() order = c.post(f"{V1}/orders", headers=eng_h, json={ "title": "sec-order", "checklist_id": cl["id"], "equipment_id": eq["id"], "assignee_user_id": ids["sec_a"], "reviewer_user_id": ids["sec_eng"]}).json() yield {"c": c, "h": h, "ids": ids, "order": order, "cl": cl, "eq": eq, "login": login} # ---------------------------------------------------------------- аутентификация PUBLIC = {("POST", "/auth/login"), ("POST", "/auth/refresh"), ("POST", "/auth/logout"), ("GET", "/health"), ("GET", "/demo/status"), ("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version")} # публичная форма инцидента: без входа, с лимитом def test_every_api_route_requires_auth(env): """Любой маршрут API без токена даёт 401 (кроме явно публичных).""" c, checked = env["c"], 0 for r in app.routes: if not isinstance(r, APIRoute) or not r.path.startswith(V1 + "/"): continue sub = r.path[len(V1):] for m in r.methods - {"HEAD", "OPTIONS"}: if (m, sub) in PUBLIC: continue path = r.path.replace("{id}", "1").replace("{item_id}", "1") resp = c.request(m, path, json={} if m in ("POST", "PUT", "PATCH") else None) assert resp.status_code == 401, f"{m} {path} -> {resp.status_code} без токена" checked += 1 assert checked > 50 def test_public_endpoints_leak_nothing(env): c = env["c"] assert c.get(f"{V1}/health").json().keys() == {"ok", "api_version", "time", "demo"} assert c.get(f"{V1}/demo/status").json() == {"active": False} def test_forged_and_invalid_tokens(env): c, uid = env["c"], env["ids"]["sec_a"] exp = datetime.now(timezone.utc) + timedelta(minutes=5) good = {"sub": str(uid), "exp": exp, "typ": "access", "mode": "real"} cases = { "чужой ключ": jwt.encode(good, "wrong-secret", algorithm="HS256"), "alg=none": jwt.encode(good, None, algorithm="none"), "просрочен": jwt.encode({**good, "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, config.SECRET_KEY, algorithm="HS256"), "режим demo в боевом": jwt.encode({**good, "mode": "demo"}, config.SECRET_KEY, algorithm="HS256"), "sub не число": jwt.encode({**good, "sub": "1 OR 1=1"}, config.SECRET_KEY, algorithm="HS256"), "нет пользователя": jwt.encode({**good, "sub": "999999"}, config.SECRET_KEY, algorithm="HS256"), "мусор": "abc.def.ghi", } for name, tok in cases.items(): assert c.get(f"{V1}/me", headers=bearer(tok)).status_code == 401, name # контроль: правильно подписанный токен работает assert c.get(f"{V1}/me", headers=bearer(jwt.encode(good, config.SECRET_KEY, algorithm="HS256"))).status_code == 200 # схема не Bearer assert c.get(f"{V1}/me", headers={"Authorization": "Basic YWRtaW46YWRtaW4="}).status_code == 401 def test_refresh_token_is_not_an_access_token(env): c = env["c"] t = env["login"]("sec_a", "Passw0rd!x") assert c.get(f"{V1}/me", headers=bearer(t["refresh_token"])).status_code == 401 def test_login_errors_do_not_enumerate_users(env): c = env["c"] a = c.post(f"{V1}/auth/login", json={"username": "sec_a", "password": "bad"}) b = c.post(f"{V1}/auth/login", json={"username": "no_such_user", "password": "bad"}) assert a.status_code == b.status_code == 401 and a.json() == b.json() def test_login_rate_limit(env): c = env["c"] codes = [c.post(f"{V1}/auth/login", json={"username": "sec_b", "password": "x"}).status_code for _ in range(7)] assert codes[:config.LOGIN_MAX_FAILS] == [401] * config.LOGIN_MAX_FAILS assert codes[-1] == 429 # блокировка действует и на верный пароль, пока не истечёт окно r = c.post(f"{V1}/auth/login", json={"username": "sec_b", "password": "Passw0rd!x"}) assert r.status_code == 429 and "Retry-After" in r.headers def test_deactivated_user_is_locked_out_immediately(env): c, h = env["c"], env["h"] r = c.post(f"{V1}/users", headers=h["admin"], json={"username": "sec_tmp", "password": "Passw0rd!x"}) uid = r.json()["id"] t = env["login"]("sec_tmp", "Passw0rd!x") assert c.get(f"{V1}/me", headers=bearer(t["access_token"])).status_code == 200 assert c.patch(f"{V1}/users/{uid}", headers=h["admin"], json={"is_active": False}).status_code == 200 assert c.get(f"{V1}/me", headers=bearer(t["access_token"])).status_code == 401 # access assert c.post(f"{V1}/auth/refresh", json={"refresh_token": t["refresh_token"]}).status_code == 401 # refresh assert c.post(f"{V1}/auth/login", json={"username": "sec_tmp", "password": "Passw0rd!x"}).status_code == 401 def test_refresh_rotation_and_reuse_detection(env): c = env["c"] t1 = env["login"]("sec_b", "Passw0rd!x") t2 = c.post(f"{V1}/auth/refresh", json={"refresh_token": t1["refresh_token"]}).json() assert t2["refresh_token"] != t1["refresh_token"] # повторное предъявление старого — отзыв ВСЕХ сессий пользователя assert c.post(f"{V1}/auth/refresh", json={"refresh_token": t1["refresh_token"]}).status_code == 401 assert c.post(f"{V1}/auth/refresh", json={"refresh_token": t2["refresh_token"]}).status_code == 401 def test_logout_revokes_refresh(env): c = env["c"] t = env["login"]("sec_b", "Passw0rd!x") assert c.post(f"{V1}/auth/logout", json={"refresh_token": t["refresh_token"]}).status_code == 204 assert c.post(f"{V1}/auth/refresh", json={"refresh_token": t["refresh_token"]}).status_code == 401 def test_password_change_and_reset_kill_other_sessions(env): c, h = env["c"], env["h"] uid = c.post(f"{V1}/users", headers=h["admin"], json={"username": "sec_pw", "password": "Passw0rd!x"}).json()["id"] t = env["login"]("sec_pw", "Passw0rd!x") other = env["login"]("sec_pw", "Passw0rd!x") n = c.post(f"{V1}/auth/change-password", headers=bearer(t["access_token"]), json={"old_password": "Passw0rd!x", "new_password": "N3wPassw0rd!"}) assert n.status_code == 200 assert c.post(f"{V1}/auth/refresh", json={"refresh_token": other["refresh_token"]}).status_code == 401 assert c.post(f"{V1}/auth/login", json={"username": "sec_pw", "password": "Passw0rd!x"}).status_code == 401 # сброс пароля администратором тоже завершает сессии cur = c.post(f"{V1}/auth/login", json={"username": "sec_pw", "password": "N3wPassw0rd!"}).json() assert c.patch(f"{V1}/users/{uid}", headers=h["admin"], json={"password": "Res3tPassw0rd!"}).status_code == 200 assert c.post(f"{V1}/auth/refresh", json={"refresh_token": cur["refresh_token"]}).status_code == 401 def test_change_password_requires_old_password(env): c, h = env["c"], env["h"] r = c.post(f"{V1}/auth/change-password", headers=h["sec_b"], json={"old_password": "wrong", "new_password": "N3wPassw0rd!"}) assert r.status_code == 403 def test_password_hash_never_exposed(env): c, h = env["c"], env["h"] for path in ("/me", "/users"): body = c.get(V1 + path, headers=h["sec_a"]).text assert "password" not in body.lower() and "$2b$" not in body # ---------------------------------------------------------------- права (RBAC) def test_technician_cannot_use_management_endpoints(env): c, t = env["c"], env["h"]["sec_a"] denied = [ ("POST", "/users", {"username": "hack", "password": "Passw0rd!x"}), ("PATCH", f"/users/{env['ids']['sec_b']}", {"full_name": "x"}), ("DELETE", f"/users/{env['ids']['sec_b']}", None), ("POST", "/positions", {"name": "x", "kind": "technician"}), ("POST", "/schedules", {"name": "x", "kind": "weekly", "weekdays": [0]}), ("POST", "/locations", {"name": "x"}), ("POST", "/equipment", {"name": "x"}), ("POST", "/checklists", {"name": "x", "items": []}), ("GET", "/order-templates", None), ("POST", "/orders", {"title": "x", "checklist_id": env["cl"]["id"]}), ("POST", f"/orders/{env['order']['id']}/cancel", None), ("GET", "/settings", None), ("PUT", "/settings", {"max_photo_mb": 99}), ("GET", "/reports", None), ("GET", "/reports/completed-orders?from=2026-01-01T00:00&to=2026-02-01T00:00", None), ("POST", "/demo/enable", {"confirm": True}), ("GET", "/demo", None), ] for m, p, body in denied: r = c.request(m, V1 + p, headers=t, json=body) assert r.status_code == 403, f"{m} {p} -> {r.status_code}" def test_engineer_cannot_manage_users_or_settings(env): c, e = env["c"], env["h"]["sec_eng"] assert c.post(f"{V1}/users", headers=e, json={"username": "hack2", "password": "Passw0rd!x"}).status_code == 403 assert c.post(f"{V1}/positions", headers=e, json={"name": "x", "kind": "technician"}).status_code == 403 assert c.get(f"{V1}/settings", headers=e).status_code == 403 def test_moderator_cannot_escalate(env): c, m, ids = env["c"], env["h"]["sec_mod"], env["ids"] admin_id = c.get(f"{V1}/me", headers=env["h"]["admin"]).json()["id"] assert c.get(f"{V1}/settings", headers=m).status_code == 403 # выдать права assert c.post(f"{V1}/users", headers=m, json={"username": "x_adm", "password": "Passw0rd!x", "is_admin": True}).status_code == 403 assert c.post(f"{V1}/users", headers=m, json={"username": "x_mod", "password": "Passw0rd!x", "is_moderator": True}).status_code == 403 assert c.patch(f"{V1}/users/{ids['sec_a']}", headers=m, json={"is_admin": True}).status_code == 403 assert c.patch(f"{V1}/users/{ids['sec_mod']}", headers=m, json={"is_admin": True}).status_code == 403 # себе # трогать администратора: сменить пароль, отключить, удалить assert c.patch(f"{V1}/users/{admin_id}", headers=m, json={"password": "Hacked1234!"}).status_code == 403 assert c.patch(f"{V1}/users/{admin_id}", headers=m, json={"is_active": False}).status_code == 403 assert c.delete(f"{V1}/users/{admin_id}", headers=m).status_code == 403 # пароль администратора не изменился assert c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).status_code == 200 def test_cannot_delete_self(env): c, a = env["c"], env["h"]["admin"] me = c.get(f"{V1}/me", headers=a).json()["id"] assert c.delete(f"{V1}/users/{me}", headers=a).status_code == 400 def test_mass_assignment_ignored(env): """Лишние поля (is_admin, password_hash) при смене профиля техником не применяются — PATCH /users ему недоступен.""" c, t = env["c"], env["h"]["sec_a"] r = c.patch(f"{V1}/users/{env['ids']['sec_a']}", headers=t, json={"is_admin": True}) assert r.status_code == 403 assert c.get(f"{V1}/me", headers=t).json()["is_admin"] is False # ---------------------------------------------------------------- изоляция данных (IDOR) def test_technician_sees_only_own_orders(env): c, h, oid = env["c"], env["h"], env["order"]["id"] assert c.get(f"{V1}/orders/{oid}", headers=h["sec_a"]).status_code == 200 assert c.get(f"{V1}/orders/{oid}", headers=h["sec_b"]).status_code == 404 assert oid not in [o["id"] for o in c.get(f"{V1}/orders", headers=h["sec_b"]).json()] # поиск и счётчики тоже не раскрывают чужие наряды assert c.get(f"{V1}/orders", headers=h["sec_b"], params={"q": "sec-order"}).json() == [] assert c.get(f"{V1}/orders/{oid}/comments", headers=h["sec_b"]).status_code == 404 def test_foreign_order_is_untouchable(env): c, b, oid = env["c"], env["h"]["sec_b"], env["order"]["id"] item = env["order"]["items"][0]["id"] for m, p, body in (("POST", f"/orders/{oid}/start", None), ("PUT", f"/orders/{oid}/items/{item}", {"value": "ok"}), ("POST", f"/orders/{oid}/submit", None), ("POST", f"/orders/{oid}/review", {"approve": True, "comment": ""}), ("POST", f"/orders/{oid}/sync", {"items": [{"item_id": item, "value": "ok"}], "submit": True}), ("POST", f"/orders/{oid}/comments", {"text": "hi"})): r = c.request(m, V1 + p, headers=b, json=body) assert r.status_code in (403, 404), f"{m} {p} -> {r.status_code}" r = c.post(f"{V1}/orders/{oid}/items/{item}/photos", headers={**b, "Content-Type": "image/png"}, content=PNG) assert r.status_code in (403, 404) def test_photo_access_is_scoped_to_order(env): c, h, o = env["c"], env["h"], env["order"] item = o["items"][0]["id"] r = c.post(f"{V1}/orders/{o['id']}/items/{item}/photos", headers={**h["sec_a"], "Content-Type": "image/png"}, content=PNG) assert r.status_code == 201, r.text pid = r.json()["items"][0]["photos"][0]["id"] assert c.get(f"{V1}/photos/{pid}", headers=h["sec_a"]).status_code == 200 assert c.get(f"{V1}/photos/{pid}", headers=h["sec_eng"]).status_code == 200 assert c.get(f"{V1}/photos/{pid}", headers=h["sec_b"]).status_code == 404 # чужой наряд assert c.get(f"{V1}/photos/{pid}").status_code == 401 # без токена assert c.delete(f"{V1}/photos/{pid}", headers=h["sec_b"]).status_code == 404 def test_cannot_answer_item_of_another_order(env): """item_id из другого наряда не принимается в рамках своего.""" c, h = env["c"], env["h"] o2 = c.post(f"{V1}/orders", headers=h["sec_eng"], json={"title": "sec-o2", "checklist_id": env["cl"]["id"], "assignee_user_id": env["ids"]["sec_a"]}).json() foreign_item = o2["items"][0]["id"] r = c.put(f"{V1}/orders/{env['order']['id']}/items/{foreign_item}", headers=h["sec_a"], json={"value": "ok"}) assert r.status_code == 404 r = c.post(f"{V1}/orders/{env['order']['id']}/sync", headers=h["sec_a"], json={"items": [{"item_id": foreign_item, "value": "ok"}]}) assert r.json()["results"][0]["status"] == "error" def test_comment_delete_only_own(env): c, h, oid = env["c"], env["h"], env["order"]["id"] cid = c.post(f"{V1}/orders/{oid}/comments", headers=h["sec_a"], json={"text": "мой"}).json()["id"] assert c.delete(f"{V1}/comments/{cid}", headers=h["sec_b"]).status_code == 403 assert c.delete(f"{V1}/comments/{cid}", headers=h["sec_a"]).status_code == 204 def test_executor_cannot_review_own_order_and_state_machine(env): c, h = env["c"], env["h"] o = c.post(f"{V1}/orders", headers=h["sec_eng"], json={"title": "sec-sm", "checklist_id": env["cl"]["id"], "assignee_user_id": env["ids"]["sec_a"], "reviewer_user_id": env["ids"]["sec_eng"]}).json() oid, items = o["id"], o["items"] assert c.post(f"{V1}/orders/{oid}/review", headers=h["sec_a"], json={"approve": True, "comment": ""}).status_code == 403 # не на проверке c.put(f"{V1}/orders/{oid}/items/{items[0]['id']}", headers=h["sec_a"], json={"value": "done"}) c.put(f"{V1}/orders/{oid}/items/{items[1]['id']}", headers=h["sec_a"], json={"value": "3"}) assert c.post(f"{V1}/orders/{oid}/submit", headers=h["sec_a"]).json()["status"] == "review" assert c.post(f"{V1}/orders/{oid}/review", headers=h["sec_a"], json={"approve": True, "comment": ""}).status_code == 403 assert c.put(f"{V1}/orders/{oid}/items/{items[1]['id']}", headers=h["sec_a"], json={"value": "4"}).status_code == 403 # после сдачи нельзя править assert c.post(f"{V1}/orders/{oid}/review", headers=h["sec_eng"], json={"approve": True, "comment": ""}).json()["status"] == "completed" assert c.post(f"{V1}/orders/{oid}/cancel", headers=h["sec_eng"]).status_code == 400 # завершённый не отменить def test_answer_validation_rejects_garbage(env): c, h = env["c"], env["h"] o = c.post(f"{V1}/orders", headers=h["sec_eng"], json={"title": "sec-val", "checklist_id": env["cl"]["id"], "assignee_user_id": env["ids"]["sec_a"]}).json() num = o["items"][1]["id"] for bad in ("abc", "1e999999", "NaN", "", "svg": b'', "exe": b"MZ\x90\x00" + b"\0" * 100, "php": b"", "empty": b"", } for name, data in payloads.items(): r = c.post(url, headers={**h["sec_a"], "Content-Type": "image/png"}, content=data) # Content-Type подделан assert r.status_code == 415, f"{name} -> {r.status_code}" def test_upload_size_limit(env): c, h, o = env["c"], env["h"], env["order"] c.put(f"{V1}/settings", headers=h["admin"], json={"max_photo_mb": 1}) try: big = PNG + b"\0" * (1024 * 1024 + 10) r = c.post(f"{V1}/orders/{o['id']}/items/{o['items'][0]['id']}/photos", headers={**h["sec_a"], "Content-Type": "image/png"}, content=big) assert r.status_code == 413 finally: c.put(f"{V1}/settings", headers=h["admin"], json={"max_photo_mb": 10}) def test_stored_photo_name_is_server_generated(env): """Имя файла на диске не зависит от клиента (нет path traversal через Idempotency-Key/заголовки).""" c, h, o = env["c"], env["h"], env["order"] r = c.post(f"{V1}/orders/{o['id']}/items/{o['items'][0]['id']}/photos", headers={**h["sec_a"], "Content-Type": "image/png", "Idempotency-Key": "../../../../etc/passwd"}, content=PNG + b"x") assert r.status_code == 201 from app.db import SessionLocal from app.models import Photo with SessionLocal() as s: for p in s.query(Photo).all(): assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(p.path.split("/")) == 3 def test_static_path_traversal_blocked(env): c = env["c"] for p in ("/static/../app/config.py", "/static/%2e%2e/app/config.py", "/static/..%2fapp%2fconfig.py", "/static/m/../../main.py", "/static/%252e%252e/app/main.py"): r = c.get(p) assert r.status_code in (400, 404) or "SECRET_KEY" not in r.text, p assert "SECRET_KEY" not in r.text # ---------------------------------------------------------------- инъекции def test_sql_injection_in_login_and_search(env): c, h = env["c"], env["h"] for u in ("admin'--", "' OR '1'='1", "admin\" OR \"1\"=\"1", "'; DROP TABLE users;--"): assert c.post(f"{V1}/auth/login", json={"username": u, "password": u}).status_code == 401 catalog._fails.clear() for q in ("'; DROP TABLE orders;--", "' OR 1=1--", "%", "_", "\\"): r = c.get(f"{V1}/orders", headers=h["admin"], params={"q": q}) assert r.status_code == 200 if q in ("%", "_", "\\"): # шаблонные символы LIKE экранируются: «%» ищется как обычный символ, а не «всё подряд» assert all(q in o["title"] for o in r.json()), q assert c.get(f"{V1}/orders", headers=h["admin"]).json() # таблица на месте assert c.get(f"{V1}/orders", headers=h["admin"], params={"status": "new' OR '1'='1"}).json() == [] def test_xss_payload_is_stored_as_text_and_served_as_json(env): c, h, oid = env["c"], env["h"], env["order"]["id"] payload = "" r = c.post(f"{V1}/orders/{oid}/comments", headers=h["sec_a"], json={"text": payload}) assert r.status_code == 201 and r.headers["content-type"].startswith("application/json") lst = c.get(f"{V1}/orders/{oid}/comments", headers=h["sec_a"]) assert lst.headers["content-type"].startswith("application/json") assert payload in lst.text def test_frontend_never_uses_innerhtml(): """Интерфейс строит DOM через h()/textContent: опасных приёмников XSS нет.""" from pathlib import Path root = Path(__file__).resolve().parents[1] / "app" / "static" bad = ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval(", "new Function(") for f in list(root.glob("*.js")) + list((root / "m").glob("*.js")): src = f.read_text(encoding="utf-8") for token in bad: if token in src: # допустимо только присваивание пустой строки (очистка контейнера) import re for m in re.finditer(re.escape(token) + r"\s*=\s*([^;\n]+)", src): assert m.group(1).strip() in ('""', "''"), f"{f.name}: {m.group(0)}" if token not in ("innerHTML",): raise AssertionError(f"{f.name}: {token}") def test_report_params_are_validated(env): c, e = env["c"], env["h"]["sec_eng"] base = f"{V1}/reports/completed-orders" assert c.get(base, headers=e, params={"from": "2026-02-01T00:00", "to": "2026-01-01T00:00"}).status_code == 422 assert c.get(base, headers=e, params={"from": "2020-01-01T00:00", "to": "2026-01-01T00:00"}).status_code == 422 # > 366 дней assert c.get(base, headers=e, params={"from": "x", "to": "y"}).status_code == 422 assert c.get(base, headers=e, params={"from": "2026-01-01T00:00", "to": "2026-02-01T00:00", "format": "../x"}).status_code == 422 # ---------------------------------------------------------------- пароли, push, лимиты, секреты def test_password_policy(env): c, a = env["c"], env["h"]["admin"] for pw in ("1234", "123456789", "x" * 73, "я" * 40): # короткий, 9 символов, >72 байт, >72 байт в UTF-8 r = c.post(f"{V1}/users", headers=a, json={"username": "sec_weak", "password": pw}) assert r.status_code == 422, pw assert c.post(f"{V1}/users", headers=a, json={"username": "sec_ok", "password": "x" * 72}).status_code == 201 uid = env["ids"]["sec_a"] assert c.patch(f"{V1}/users/{uid}", headers=a, json={"password": "short"}).status_code == 422 r = c.post(f"{V1}/auth/change-password", headers=env["h"]["sec_b"], json={"old_password": "Passw0rd!x", "new_password": "short"}) assert r.status_code == 422 def test_long_password_does_not_crash(env): r = env["c"].post(f"{V1}/users", headers=env["h"]["admin"], json={"username": "sec_long", "password": "A" * 100}) assert r.status_code == 422 # раньше bcrypt молча обрезал до 72 байт def test_push_endpoint_is_restricted(env): c, h, keys = env["c"], env["h"]["sec_a"], {"p256dh": "x" * 20, "auth": "y" * 10} bad = ["http://127.0.0.1:8000/api/v1/health", "https://127.0.0.1/x", "https://localhost/x", "https://[::1]/x", "https://192.168.0.3/x", "https://169.254.169.254/latest/meta-data", "http://fcm.googleapis.com/fcm/send/x", "https://evil.example.com/x", "https://fcm.googleapis.com.evil.com/x", "https://evilgoogleapis.com/x", "https://user:pw@fcm.googleapis.com/x", "https://fcm.googleapis.com:8443/x", "file:///etc/passwd", "gopher://x", ""] for ep in bad: r = c.post(f"{V1}/push/subscribe", headers=h, json={"endpoint": ep, "keys": keys}) assert r.status_code == 422, ep good = ["https://fcm.googleapis.com/fcm/send/abc", "https://updates.push.services.mozilla.com/wpush/v2/abc", "https://web.push.apple.com/abc", "https://wns2-par02p.notify.windows.com/?token=abc"] for ep in good: assert c.post(f"{V1}/push/subscribe", headers=h, json={"endpoint": ep, "keys": keys}).status_code == 204, ep assert c.post(f"{V1}/push/unsubscribe", headers=h, json={"endpoint": ep, "keys": {}}).status_code == 204 # без ключей подписка не принимается (раньше — 500) r = c.post(f"{V1}/push/subscribe", headers=h, json={"endpoint": good[0], "keys": {}}) assert r.status_code == 422 def test_login_lockout_is_per_address_and_login(env): """Посторонний не может заблокировать вход настоящему пользователю (другой адрес), но перебор ограничен.""" victim, other = env["c"], TestClient(app, client=("203.0.113.9", 4000)) body = {"username": "sec_b", "password": "wrong-password"} codes = [other.post(f"{V1}/auth/login", json=body).status_code for _ in range(config.LOGIN_MAX_FAILS + 1)] assert codes[-1] == 429 # злоумышленник заблокирован… ok = victim.post(f"{V1}/auth/login", json={"username": "sec_b", "password": "Passw0rd!x"}) assert ok.status_code == 200 # …а владелец учётной записи входит # перебор по разным логинам с одного адреса упирается в общий предел адреса catalog._fails.clear() n = config.LOGIN_MAX_FAILS * 6 codes = [other.post(f"{V1}/auth/login", json={"username": f"user{i}", "password": "x"}).status_code for i in range(n + 2)] assert 429 in codes and codes.index(429) == n # распределённый перебор одного логина с разных адресов упирается в общий предел логина catalog._fails.clear() codes = [] for i in range(config.LOGIN_MAX_FAILS * 4 + 1): cl = TestClient(app, client=(f"198.51.100.{i + 1}", 5000)) codes.append(cl.post(f"{V1}/auth/login", json={"username": "sec_b", "password": "bad"}).status_code) assert codes[-1] == 429 def test_rate_limiter_memory_is_bounded(): catalog._fails.clear() old = time.time() - config.LOGIN_WINDOW_SEC - 10 for i in range(catalog.MAX_TRACKED + 500): catalog._fails[f"ipuser:x{i}|u"] = [old] catalog._check_rate("ip:probe", 5) assert len(catalog._fails) < 10 now = time.time() for i in range(catalog.MAX_TRACKED + 500): # свежие записи не истекли — предел всё равно действует catalog._fails[f"k{i}"] = [now] catalog._check_rate("ip:probe", 5) assert len(catalog._fails) <= catalog.MAX_TRACKED catalog._fails.clear() def test_settings_show_client_address_for_proxy_check(env): r = env["c"].get(f"{V1}/settings", headers={**env["h"]["admin"], "X-Forwarded-For": "1.2.3.4"}) assert r.status_code == 200 and "client_ip" in r.json() and r.json()["forwarded_for"] == "1.2.3.4" def test_weak_secrets_refuse_to_start_in_production(monkeypatch): from app import main monkeypatch.setattr(config, "PRODUCTION", True) for key in ("", "dev-secret-change-me", "short-key", "a" * 31): monkeypatch.setattr(config, "SECRET_KEY", key) with pytest.raises(RuntimeError): main.check_secrets() monkeypatch.setattr(config, "SECRET_KEY", "k" * 40) main.check_secrets() # нормальный ключ — запуск разрешён monkeypatch.setattr(config, "ADMIN_PASSWORD", "admin") main.check_secrets(users_exist=True) # пароль админа проверяется только при первом создании with pytest.raises(RuntimeError): main.check_secrets(users_exist=False) monkeypatch.setattr(config, "PRODUCTION", False) monkeypatch.setattr(config, "SECRET_KEY", "") main.check_secrets() # в разработке — только предупреждение def test_deploy_files_do_not_trust_all_proxies(): from pathlib import Path root = Path(__file__).resolve().parents[1] docker = (root / "Dockerfile").read_text(encoding="utf-8") compose = (root / "docker-compose.yml").read_text(encoding="utf-8") assert "forwarded-allow-ips \\\"*\\\"" not in docker and "FORWARDED_ALLOW_IPS" in docker assert "FORWARDED_ALLOW_IPS" in compose and "APP_ENV: production" in compose def test_android_forbids_cleartext_in_release(): from pathlib import Path android = Path(__file__).resolve().parents[1] / "android" / "app" / "src" manifest = (android / "main" / "AndroidManifest.xml").read_text(encoding="utf-8") assert 'usesCleartextTraffic="true"' not in manifest and "networkSecurityConfig" in manifest cfg = (android / "main" / "res" / "xml" / "network_security_config.xml").read_text(encoding="utf-8") assert 'cleartextTrafficPermitted="false"' in cfg and 'cleartextTrafficPermitted="true"' not in cfg # ---------------------------------------------------------------- известные недостатки (xfail) @pytest.mark.xfail(reason="SEC-05: нет заголовков безопасности (nosniff, X-Frame-Options, CSP)", strict=True) def test_gap_security_headers(env): r = env["c"].get("/") assert r.headers.get("x-content-type-options") == "nosniff" assert r.headers.get("x-frame-options") or "frame-ancestors" in r.headers.get("content-security-policy", "") def test_csv_formula_injection_is_escaped(env): from app.reports import to_csv row = {"order_id": 1, "finished_at": None, "equipment": "=HYPERLINK(\"http://evil\")", "inventory_no": "", "location": "", "checklist": "+cmd", "executor": "@x", "position": "-1", "status": "completed", "deviations": 0} cells = to_csv([row]).decode("utf-8-sig").splitlines()[1].split(";") assert not any(x.lstrip('"').startswith(("=", "+", "-", "@")) for x in cells[2:8]) assert cells[2].lstrip('"').startswith("'=HYPERLINK") @pytest.mark.xfail(reason="SEC-07: документация API (/docs, /openapi.json) доступна без авторизации", strict=True) def test_gap_docs_public(env): assert env["c"].get("/openapi.json").status_code in (401, 403, 404)