Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+451
View File
@@ -0,0 +1,451 @@
import os
import pytest
import tempfile
from datetime import datetime, date, time
# TEST_DATABASE_URL позволяет прогнать те же тесты на PostgreSQL (по умолчанию — временный файл SQLite)
os.environ["DATABASE_URL"] = os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "t.db").replace("\\", "/")
os.environ["SCHEDULER_INTERVAL"] = "3600"
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef")
_DATA = tempfile.mkdtemp()
os.environ["DATA_DIR"] = _DATA
from fastapi.testclient import TestClient # noqa: E402
from app import logic # noqa: E402
from app.main import app # noqa: E402
from app.models import ShiftSchedule, OrderTemplate # noqa: E402
def test_shifts_and_recurrence():
cyc = ShiftSchedule(kind="cycle", work_days=2, off_days=2, cycle_start=date(2026, 1, 1),
start_time=time(20, 0), end_time=time(8, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 1, 22, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 2, 3, 0)) # ночная смена с 1-го
assert logic.shift_active(cyc, datetime(2026, 1, 2, 23, 0))
assert logic.shift_active(cyc, datetime(2026, 1, 3, 3, 0)) # смена со 2-го идёт до 8:00 3-го
assert not logic.shift_active(cyc, datetime(2026, 1, 3, 22, 0)) # 3-е — выходной
wk = ShiftSchedule(kind="weekly", weekdays=[0], start_time=time(8, 0), end_time=time(17, 0))
assert logic.shift_active(wk, datetime(2026, 1, 5, 9, 0)) # понедельник
assert not logic.shift_active(wk, datetime(2026, 1, 6, 9, 0))
t = OrderTemplate(recurrence="monthly", month_day=31, run_time=time(8, 0), start_date=date(2026, 1, 1))
assert logic.next_run_after(t, datetime(2026, 2, 1)) == datetime(2026, 2, 28, 8, 0)
t = OrderTemplate(recurrence="interval", interval_days=3, run_time=time(6, 0), start_date=date(2026, 1, 1))
assert logic.next_run_after(t, datetime(2026, 1, 2)) == datetime(2026, 1, 4, 6, 0)
def test_full_flow():
with TestClient(app) as c:
def login(u, p):
r = c.post("/api/auth/login", json={"username": u, "password": p})
assert r.status_code == 200, r.text
return {"Authorization": "Bearer " + r.json()["access_token"]}
adm = login("admin", "admin")
sch = c.post("/api/schedules", headers=adm, json={"name": "Всегда", "kind": "weekly",
"weekdays": [0, 1, 2, 3, 4, 5, 6], "start_time": "00:00:00", "end_time": "23:59:00"}).json()
night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly",
"weekdays": []}).json()
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician",
"schedule_id": sch["id"]}).json()
tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician",
"schedule_id": night["id"]}).json()
eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).json()
for name, pos in (("ivan", tech), ("petr", tech2), ("eng", eng)):
r = c.post("/api/users", headers=adm, json={"username": name, "password": "Pass1234567",
"position_id": pos["id"]})
assert r.status_code == 201, r.text
ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567")
# технику нельзя создавать справочники
assert c.post("/api/locations", headers=ivan, json={"name": "x"}).status_code == 403
loc = c.post("/api/locations", headers=engh, json={"name": "Цех 1"}).json()
eq = c.post("/api/equipment", headers=engh, json={"name": "Насос Н-1", "location_id": loc["id"]}).json()
cl = c.post("/api/checklists", headers=engh, json={"name": "ТО насоса", "items": [
{"text": "Осмотр", "answer_type": "status"},
{"text": "Давление", "answer_type": "number", "unit": "бар", "min_value": 2, "max_value": 6},
{"text": "Состояние", "answer_type": "choice", "options": ["норма", "износ"]},
{"text": "Заметки", "answer_type": "text", "required": False}]})
assert cl.status_code == 201, cl.text
cl = cl.json()
assert c.post("/api/checklists", headers=engh, json={"name": "bad", "items": [
{"text": "a", "answer_type": "choice"}]}).status_code == 422
# шаблон по расписанию на должность
tpl = c.post("/api/order-templates", headers=engh, json={
"title": "Ежедневное ТО Н-1", "checklist_id": cl["id"], "equipment_id": eq["id"],
"location_id": loc["id"], "assignee_position_id": tech["id"], "reviewer_position_id": eng["id"],
"recurrence": "daily"}).json()
assert tpl["next_run"]
order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"]
# видимость: Иван (на смене) видит, Пётр (выходной) — нет
assert [o["id"] for o in c.get("/api/orders", headers=ivan).json()] == [order_id]
assert c.get("/api/orders", headers=petr).json() == []
assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404
o = c.get(f"/api/orders/{order_id}", headers=ivan).json()
assert o["can_execute"] and o["status"] == "new"
ids = [i["id"] for i in o["items"]]
# неверные ответы
assert c.put(f"/api/orders/{order_id}/items/{ids[2]}", headers=ivan, json={"value": "??"}).status_code == 422
assert c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "abc"}).status_code == 422
# сдать рано нельзя
assert c.post(f"/api/orders/{order_id}/submit", headers=ivan).status_code == 422
c.put(f"/api/orders/{order_id}/items/{ids[0]}", headers=ivan, json={"value": "done"})
o = c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "7,5"}).json()
assert o["status"] == "in_progress" and o["items"][1]["out_of_range"]
c.put(f"/api/orders/{order_id}/items/{ids[2]}", headers=ivan, json={"value": "норма"})
o = c.post(f"/api/orders/{order_id}/submit", headers=ivan).json()
assert o["status"] == "review" and o["done_count"] == 3
# техник не может проверять; инженер возвращает, затем принимает
assert c.post(f"/api/orders/{order_id}/review", headers=ivan, json={"approve": True}).status_code == 403
assert c.post(f"/api/orders/{order_id}/review", headers=engh, json={"approve": False}).status_code == 422
o = c.post(f"/api/orders/{order_id}/review", headers=engh,
json={"approve": False, "comment": "Давление вне нормы"}).json()
assert o["status"] == "in_progress" and o["review_comment"]
c.put(f"/api/orders/{order_id}/items/{ids[1]}", headers=ivan, json={"value": "4"})
c.post(f"/api/orders/{order_id}/submit", headers=ivan)
o = c.post(f"/api/orders/{order_id}/review", headers=engh, json={"approve": True}).json()
assert o["status"] == "completed"
# ручной наряд сразу на человека, без проверяющего
r = c.post("/api/orders", headers=engh, json={"title": "Разовый", "checklist_id": cl["id"],
"assignee_user_id": 2}).json()
assert c.get("/api/orders/stats", headers=engh).json()["by_status"]["completed"] == 1
assert r["status"] == "new"
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 100
def test_comments_photos_roles_push(monkeypatch):
from app import push
sent = []
monkeypatch.setattr(push, "notify", lambda ids, title, body, url="/": sent.append((sorted(i for i in ids if i), title)))
with TestClient(app) as c:
def login(u, p):
return {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
mod = c.post("/api/users", headers=adm, json={"username": "mod", "password": "Pass1234567", "is_moderator": True}).json()
assert mod["is_moderator"]
modh = login("mod", "Pass1234567")
# модератор: управляет пользователями/должностями, но не админами и не сервером
assert c.post("/api/positions", headers=modh, json={"name": "Электрик"}).status_code == 201
assert c.post("/api/users", headers=modh, json={"username": "u1", "password": "Pass1234567"}).status_code == 201
assert c.post("/api/users", headers=modh, json={"username": "u2", "password": "Pass1234567", "is_admin": True}).status_code == 403
assert c.patch("/api/users/1", headers=modh, json={"full_name": "x"}).status_code == 403
assert c.delete("/api/users/1", headers=modh).status_code == 403
assert c.get("/api/settings", headers=modh).status_code == 403
assert c.get("/api/settings", headers=adm).status_code == 200
tech = c.post("/api/positions", headers=modh, json={"name": "Т"}).json()
uid = c.post("/api/users", headers=modh, json={"username": "t1", "password": "Pass1234567", "position_id": tech["id"]}).json()["id"]
t1, other = login("t1", "Pass1234567"), login("u1", "Pass1234567")
cl = c.post("/api/checklists", headers=modh, json={"name": "Ф", "items": [
{"text": "Снимок", "answer_type": "status", "photo": "required"},
{"text": "Без фото", "answer_type": "text", "photo": "none", "required": False}]}).json()
o = c.post("/api/orders", headers=modh, json={"title": "Фото-наряд", "checklist_id": cl["id"], "assignee_user_id": uid}).json()
assert (sorted([uid]), "Новый наряд") in sent
oid, i1, i2 = o["id"], o["items"][0]["id"], o["items"][1]["id"]
assert not o["can_execute"] # модератор не исполнитель
# настройка каталога фото
import tempfile
d = tempfile.mkdtemp()
assert c.put("/api/settings", headers=adm, json={"photo_dir": d, "max_photo_mb": 1}).json()["photo_dir"] == d
c.put(f"/api/orders/{oid}/items/{i1}", headers=t1, json={"value": "done"})
assert c.post(f"/api/orders/{oid}/submit", headers=t1).status_code == 422 # нет обязательного фото
up = lambda h, item, body, ct="image/png": c.post(f"/api/orders/{oid}/items/{item}/photos", headers={**h, "Content-Type": ct}, content=body)
assert up(t1, i2, PNG).status_code == 400 # фото не предусмотрено
assert up(t1, i1, b"not an image").status_code == 415
assert up(t1, i1, PNG + b"0" * 1_100_000).status_code == 413
assert up(other, i1, PNG).status_code == 404 # чужой наряд не виден
r = up(t1, i1, PNG)
assert r.status_code == 201, r.text
pid = r.json()["items"][0]["photos"][0]["id"]
assert c.get(f"/api/photos/{pid}", headers=t1).content == PNG
assert c.get(f"/api/photos/{pid}", headers=other).status_code == 404
assert c.get(f"/api/photos/{pid}").status_code == 401
assert c.post(f"/api/orders/{oid}/submit", headers=t1).status_code == 200
# комментарии
sent.clear()
assert c.post(f"/api/orders/{oid}/comments", headers=t1, json={"text": "Готово, замечаний нет"}).status_code == 201
assert c.post(f"/api/orders/{oid}/comments", headers=other, json={"text": "x"}).status_code == 404
cm = c.post(f"/api/orders/{oid}/comments", headers=modh, json={"text": "Принято"}).json()
assert [x["text"] for x in c.get(f"/api/orders/{oid}/comments", headers=t1).json()] == ["Готово, замечаний нет", "Принято"]
assert c.get(f"/api/orders/{oid}", headers=t1).json()["comments_count"] == 2
assert c.delete(f"/api/comments/{cm['id']}", headers=t1).status_code == 403
assert c.delete(f"/api/comments/{cm['id']}", headers=modh).status_code == 204
assert any(t.startswith("Комментарий") for _, t in sent)
# push-подписка
key = c.get("/api/push/key", headers=t1).json()["key"]
assert len(key) > 60
sub = {"endpoint": "https://fcm.googleapis.com/fcm/send/abc", "keys": {"p256dh": "k", "auth": "a"}}
assert c.post("/api/push/subscribe", headers=t1, json=sub).status_code == 204
assert c.post("/api/push/subscribe", headers=t1, json=sub).status_code == 204 # идемпотентно
assert c.post("/api/push/unsubscribe", headers=t1, json=sub).status_code == 204
def test_api_v1_tokens_sync(monkeypatch):
from app import push
monkeypatch.setattr(push, "notify", lambda *a, **k: None)
with TestClient(app) as c:
# версия и формат времени
h = c.get("/api/v1/health").json()
assert h["api_version"] == 1 and h["time"][-6] in "+-" # смещение часового пояса
assert c.get("/api/health").status_code == 200 # старый путь работает
assert "/api/v1/orders" in c.get("/openapi.json").json()["paths"]
tok = c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin", "device": "pytest"}).json()
assert tok["expires_in"] == 3600 and tok["refresh_token"]
adm = {"Authorization": "Bearer " + tok["access_token"]}
# refresh с ротацией, повтор старого токена отзывает сессию
t2 = c.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}).json()
assert t2["refresh_token"] != tok["refresh_token"]
assert c.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/refresh", json={"refresh_token": t2["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/refresh", json={"refresh_token": "junk"}).status_code == 401
t3 = c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()
adm = {"Authorization": "Bearer " + t3["access_token"]}
c.post("/api/v1/auth/logout", json={"refresh_token": t3["refresh_token"]})
assert c.post("/api/v1/auth/refresh", json={"refresh_token": t3["refresh_token"]}).status_code == 401
# ограничение попыток входа
for _ in range(5):
assert c.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}).status_code == 401
assert c.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}).status_code == 429
_fails_reset()
# смена пароля завершает остальные сессии
c.post("/api/v1/users", headers=adm, json={"username": "sync1", "password": "Pass1234567"})
lg = lambda p: c.post("/api/v1/auth/login", json={"username": "sync1", "password": p}).json()
a, b = lg("Pass1234567"), lg("Pass1234567")
ha = {"Authorization": "Bearer " + a["access_token"]}
assert c.post("/api/v1/auth/change-password", headers=ha, json={"old_password": "bad", "new_password": "NewPass12345"}).status_code == 403
n = c.post("/api/v1/auth/change-password", headers=ha, json={"old_password": "Pass1234567", "new_password": "NewPass12345"}).json()
assert c.post("/api/v1/auth/refresh", json={"refresh_token": n["refresh_token"]}).status_code == 200
assert c.post("/api/v1/auth/refresh", json={"refresh_token": b["refresh_token"]}).status_code == 401
assert c.post("/api/v1/auth/login", json={"username": "sync1", "password": "Pass1234567"}).status_code == 401
uid = c.get("/api/v1/users", headers=adm).json()[-1]["id"]
# офлайн-синхронизация
cl = c.post("/api/v1/checklists", headers=adm, json={"name": "S", "items": [
{"text": "a", "answer_type": "status"}, {"text": "n", "answer_type": "number", "min_value": 1, "max_value": 5},
{"text": "ph", "answer_type": "text", "required": False, "photo": "optional"}]}).json()
o = c.post("/api/v1/orders", headers=adm, json={"title": "Офлайн", "checklist_id": cl["id"], "assignee_user_id": uid,
"due_at": "2026-12-01T10:00:00+03:00"}).json()
assert o["due_at"].startswith("2026-12-01T10:00:00")
assert c.post("/api/v1/orders", headers=adm, json={"title": "UTC", "checklist_id": cl["id"],
"due_at": "2026-12-01T07:00:00Z"}).json()["due_at"].startswith("2026-12-01T10:00:00+03:00")
sh = {"Authorization": "Bearer " + lg("NewPass12345")["access_token"]}
ids = [i["id"] for i in o["items"]]
r = c.post(f"/api/v1/orders/{o['id']}/sync", headers=sh, json={"items": [
{"item_id": ids[0], "value": "done", "answered_at": "2026-10-07T12:00:00+03:00"},
{"item_id": ids[1], "value": "99", "answered_at": "2026-10-07T12:01:00+03:00"},
{"item_id": ids[1], "value": "zzz"}, {"item_id": 99999, "value": "x"}], "submit": True}).json()
assert [x["status"] for x in r["results"]] == ["ok", "ok", "error", "error"]
assert r["submitted"] and r["order"]["status"] == "completed" and r["order"]["items"][1]["out_of_range"]
# повторная отправка — безопасна и не ломается (наряд уже сдан)
assert c.post(f"/api/v1/orders/{o['id']}/sync", headers=sh, json={"items": []}).status_code == 403
# устаревший ответ не затирает более свежий
o2 = c.post("/api/v1/orders", headers=adm, json={"title": "Конфликт", "checklist_id": cl["id"], "assignee_user_id": uid}).json()
i2 = o2["items"][1]["id"]
c.put(f"/api/v1/orders/{o2['id']}/items/{i2}", headers=sh, json={"value": "3"})
r = c.post(f"/api/v1/orders/{o2['id']}/sync", headers=sh, json={"items": [
{"item_id": i2, "value": "1", "answered_at": "2020-01-01T00:00:00Z"}]}).json()
assert r["results"][0]["status"] == "stale" and r["order"]["items"][1]["value"] == "3"
# updated_since + include_items
full = c.get("/api/v1/orders?include_items=true", headers=sh).json()
assert all(x["items"] is not None for x in full)
cursor = max(x["updated_at"] for x in full)
assert c.get("/api/v1/orders", headers=sh, params={"updated_since": cursor}).json() == []
c.post(f"/api/v1/orders/{o2['id']}/comments", headers=adm, json={"text": "ping"})
ch = c.get("/api/v1/orders", headers=sh, params={"updated_since": cursor}).json()
assert [x["id"] for x in ch] == [o2["id"]]
# идемпотентная загрузка фото
up = lambda: c.post(f"/api/v1/orders/{o2['id']}/items/{o2['items'][2]['id']}/photos",
headers={**sh, "Content-Type": "image/png", "Idempotency-Key": "abc-1"}, content=PNG)
assert up().status_code == 201
assert len(up().json()["items"][2]["photos"]) == 1
# FCM: регистрация устройства и отправка
tk = "x" * 40
assert c.post("/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
assert c.post("/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
import httpx
from app.db import SessionLocal
from app import settings_store as ss
calls = []
class Creds:
project_id, token, valid = "proj", "tok", True
monkeypatch.setattr(push, "_fcm_auth", lambda p: Creds)
monkeypatch.setattr(httpx, "post", lambda url, **kw: (calls.append((url, kw["json"])), type("R", (), {"status_code": 200, "text": ""})())[1])
with SessionLocal() as db:
push._send_fcm(db, {"title": "T", "body": "B", "url": "/x"}, [uid])
assert calls == [] # FCM не настроен
ss.set_(db, "fcm_credentials_path", "dummy.json")
db.commit()
push._send_fcm(db, {"title": "T", "body": "B", "url": "/x"}, [uid])
assert calls[0][0].endswith("/projects/proj/messages:send") and calls[0][1]["message"]["token"] == tk
assert c.request("DELETE", "/api/v1/push/device", headers=sh, json={"token": tk}).status_code == 204
def _fails_reset():
from app.routers import catalog
catalog._fails.clear()
def test_pattern_schedule():
"""3 дня / отсыпной / 3 ночи / отсыпной / 3 выходных (11 дней), и «сутки через трое»."""
from app.models import ShiftSchedule
from datetime import date, datetime
W = lambda d, a, b, label="": {"type": "work", "days": d, "start_time": a, "end_time": b, "label": label}
O = lambda d: {"type": "off", "days": d}
s = ShiftSchedule(kind="pattern", cycle_start=date(2026, 1, 1),
pattern=[W(3, "08:00", "20:00", "день"), O(1), W(3, "20:00", "08:00", "ночь"), O(1), O(3)])
at = lambda m, d, h, mi=0: logic.shift_active(s, datetime(2026, m, d, h, mi))
assert at(1, 1, 9) and at(1, 3, 19) and not at(1, 3, 21) and not at(1, 4, 12) # дни, потом отсыпной
assert not at(1, 5, 12) and at(1, 5, 21) and at(1, 6, 3) # ночь 5-го уходит на 6-е
assert at(1, 7, 7) and not at(1, 7, 9) # ночь 7-го закончилась 8:00 8-го? нет — 7-го в 7:00 идёт смена 6-го
assert not at(1, 8, 9) and not at(1, 8, 21) # 8-е — отсыпной после ночей
assert not at(1, 9, 12) and not at(1, 10, 12) and not at(1, 11, 12) # 3 выходных
assert at(1, 12, 9) # цикл 11 дней -> 12 янв снова день
sut = ShiftSchedule(kind="pattern", cycle_start=date(2026, 1, 1), pattern=[W(1, "08:00", "08:00"), O(3)])
assert logic.shift_active(sut, datetime(2026, 1, 1, 8, 0)) and logic.shift_active(sut, datetime(2026, 1, 2, 7, 59))
assert not logic.shift_active(sut, datetime(2026, 1, 2, 8, 0)) and logic.shift_active(sut, datetime(2026, 1, 5, 12))
assert logic.shift_starting(ShiftSchedule(kind="pattern", pattern=[]), date(2026, 1, 1)) is None
def test_schedule_api_pattern():
with TestClient(app) as c:
h = {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
body = {"name": "Сутки/трое", "kind": "pattern", "cycle_start": "2026-01-01",
"pattern": [{"type": "work", "days": 1, "start_time": "08:00", "end_time": "08:00"}, {"type": "off", "days": 3}]}
r = c.post("/api/v1/schedules", headers=h, json=body)
assert r.status_code == 201, r.text
assert r.json()["pattern"][0]["start_time"] == "08:00:00"
assert c.post("/api/v1/schedules", headers=h, json={**body, "name": "bad", "pattern": [{"type": "off", "days": 2}]}).status_code == 422
pv = c.post("/api/v1/schedules/preview", headers=h, json={"schedule": body, "from_date": "2026-01-01", "days": 5}).json()
assert [d["work"] for d in pv] == [True, False, False, False, True]
assert pv[0]["end"].startswith("2026-01-02T08:00:00")
sid = r.json()["id"]
up = c.patch(f"/api/v1/schedules/{sid}", headers=h, json={"pattern": [{"type": "work", "days": 2}, {"type": "off", "days": 1}]})
assert up.status_code == 200 and up.json()["pattern"][0]["days"] == 2
assert c.get("/api/v1/schedules", headers=h).status_code == 200
def test_report_completed_orders():
from datetime import datetime, timedelta
with TestClient(app) as c:
H = lambda u, p: {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = H("admin", "admin")
pos = c.post("/api/v1/positions", headers=adm, json={"name": "Слесарь РП"}).json()
eng = c.post("/api/v1/positions", headers=adm, json={"name": "Инженер РП", "kind": "engineer"}).json()
c.post("/api/v1/users", headers=adm, json={"username": "rep_t", "full_name": "Пётр Сидоров", "password": "Pass1234567", "position_id": pos["id"]})
c.post("/api/v1/users", headers=adm, json={"username": "rep_e", "password": "Pass1234567", "position_id": eng["id"]})
uid = [u for u in c.get("/api/v1/users", headers=adm).json() if u["username"] == "rep_t"][0]["id"]
t, e = H("rep_t", "Pass1234567"), H("rep_e", "Pass1234567")
eq = c.post("/api/v1/equipment", headers=adm, json={"name": "Компрессор К-2", "inventory_no": "K-002"}).json()
cl = c.post("/api/v1/checklists", headers=adm, json={"name": "ТО компрессора", "items": [{"text": "осмотр", "answer_type": "status"}]}).json()
o = c.post("/api/v1/orders", headers=adm, json={"title": "ТО К-2", "checklist_id": cl["id"], "equipment_id": eq["id"], "assignee_user_id": uid}).json()
c.put(f"/api/v1/orders/{o['id']}/items/{o['items'][0]['id']}", headers=t, json={"value": "done"})
assert c.post(f"/api/v1/orders/{o['id']}/submit", headers=t).json()["status"] == "completed"
# смена должности после выполнения не меняет отчёт
other = c.post("/api/v1/positions", headers=adm, json={"name": "Другая"}).json()
c.patch(f"/api/v1/users/{uid}", headers=adm, json={"position_id": other["id"]})
now = datetime.now()
q = {"from": (now - timedelta(hours=2)).isoformat(), "to": (now + timedelta(hours=2)).isoformat()}
j = c.get("/api/v1/reports/completed-orders", headers=e, params=q).json()
r = [x for x in j["rows"] if x["order_id"] == o["id"]][0]
assert (r["equipment"], r["checklist"], r["executor"], r["position"]) == ("Компрессор К-2", "ТО компрессора", "Пётр Сидоров", "Слесарь РП")
assert r["finished_at"][-6] in "+-"
# фильтры
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "equipment_id": 99999}).json()["total"] == 0
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "position_id": pos["id"]}).json()["total"] == 1
far = {"from": (now + timedelta(days=1)).isoformat(), "to": (now + timedelta(days=2)).isoformat()}
assert c.get("/api/v1/reports/completed-orders", headers=e, params=far).json()["total"] == 0
# обязательность и корректность периода, права
assert c.get("/api/v1/reports/completed-orders", headers=e).status_code == 422
assert c.get("/api/v1/reports/completed-orders", headers=e, params={"from": q["to"], "to": q["from"]}).status_code == 422
assert c.get("/api/v1/reports/completed-orders", headers=t, params=q).status_code == 403
# экспорт
pdf = c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "format": "pdf"})
assert pdf.status_code == 200 and pdf.content[:5] == b"%PDF-" and "attachment" in pdf.headers["content-disposition"]
assert c.get("/api/v1/reports/completed-orders", headers=e, params={**far, "format": "pdf"}).content[:5] == b"%PDF-" # пустой отчёт
csv = c.get("/api/v1/reports/completed-orders", headers=e, params={**q, "format": "csv"}).content.decode("utf-8-sig")
assert "Компрессор К-2" in csv and "Слесарь РП" in csv
assert [x["id"] for x in c.get("/api/v1/reports", headers=e).json()] == ["completed-orders", "equipment-state", "execution", "people"]
@pytest.mark.skipif(not os.environ["DATABASE_URL"].startswith("sqlite"), reason="проверка неизменности файла БД — только для SQLite")
def test_order_search():
with TestClient(app) as c:
h = {"Authorization": "Bearer " + c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
cl = c.post("/api/v1/checklists", headers=h, json={"name": "П", "items": []}).json()
eq = c.post("/api/v1/equipment", headers=h, json={"name": "Гидронасос ЖЕЛТЫЙ"}).json()
mk = lambda t, **k: c.post("/api/v1/orders", headers=h, json={"title": t, "checklist_id": cl["id"], **k}).json()["id"]
a = mk("Обход Котельной"); b = mk("Смазка 100%_готово"); d = mk("Прочее", equipment_id=eq["id"])
s = lambda q: sorted(o["id"] for o in c.get("/api/v1/orders", headers=h, params={"q": q}).json())
assert s("котел") == [a] and s("КОТЕЛ") == [a] # регистр кириллицы не важен
assert s("желтый") == [d] # по названию оборудования
assert s("100%_") == [b] and s("%") == [b] # спецсимволы LIKE — буквально
assert s("#%d" % d) == [d] and s(str(a)) == [a] # по номеру
assert s("нет такого") == [] and s(" ") != []
def test_apk_download(monkeypatch, tmp_path):
from pathlib import Path
import os, time
from app import config
bundled = tmp_path / "release" # APK, поставляемый в образе
bundled.mkdir()
monkeypatch.setattr(config, "RELEASE_DIR", bundled)
with TestClient(app) as c:
assert c.get("/app.apk").status_code == 404 # нигде нет файла
(bundled / "image.apk").write_bytes(b"PK-image")
r = c.get("/app.apk") # есть только APK из образа -> раздаётся он
assert r.status_code == 200 and r.content == b"PK-image"
assert r.headers["content-type"] == "application/vnd.android.package-archive"
assert c.head("/app.apk").status_code == 200
d = Path(_DATA) / "apk" # загруженный вручную важнее и берётся самый свежий
d.mkdir(exist_ok=True)
(d / "old.apk").write_bytes(b"PK-old")
os.utime(d / "old.apk", (time.time() - 100, time.time() - 100))
(d / "new.apk").write_bytes(b"PK-new")
assert c.get("/app.apk").content == b"PK-new"
for f in d.glob("*.apk"):
f.unlink()
assert c.get("/app.apk").content == b"PK-image" # после удаления снова APK из образа
def test_mobile_pwa_assets():
import json
with TestClient(app) as c:
r = c.get("/m")
assert r.status_code == 200 and "text/html" in r.headers["content-type"] and "/static/m/m.js" in r.text
assert c.get("/m/").status_code == 200
man = json.loads(c.get("/static/m/manifest.webmanifest").text)
assert man["start_url"] == "/m" and man["display"] == "standalone"
for icon in man["icons"]:
png = c.get(icon["src"])
assert png.status_code == 200 and png.content[:8] == b"\x89PNG\r\n\x1a\n", icon["src"]
sw = c.get("/sw.js")
assert sw.status_code == 200 and "shell-m" in sw.text and sw.headers["service-worker-allowed"] == "/"
# все файлы оболочки из service worker реально существуют
import re
shell = re.search(r"const SHELL = \[(.*?)\];", sw.text, re.S).group(1)
for path in re.findall(r'"([^"]+)"', shell):
assert c.get(path).status_code == 200, path
+116
View File
@@ -0,0 +1,116 @@
"""Демо-режим персональный: в демо работает только тот, кто его включил (или кого включил администратор)."""
import os
import sqlite3
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "dm.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config, db as dbm, demo, mode # noqa: E402
from app.main import app # noqa: E402
def test_demo_mode_personal():
real_path = os.environ["DATABASE_URL"].replace("sqlite:///", "")
data_dir = Path(config.DATA_DIR)
with TestClient(app) as c:
H = lambda t: {"Authorization": "Bearer " + t}
tk = c.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()
adm = H(tk["access_token"])
rt_user = c.post("/api/v1/users", headers=adm, json={"username": "realtech_dm", "password": "Pass1234567"}).json()
c.post("/api/v1/equipment", headers=adm, json={"name": "БОЕВОЙ-СТАНОК"})
rt = H(c.post("/api/v1/auth/login", json={"username": "realtech_dm", "password": "Pass1234567"}).json()["access_token"])
def real_snapshot():
con = sqlite3.connect(real_path)
try:
return {t: con.execute(f"select count(*) from {t}").fetchone()[0] for t in ("users", "equipment", "orders", "checklists", "positions")}
finally:
con.close()
real_names = sorted(e["name"] for e in c.get("/api/v1/equipment", headers=adm).json())
before = real_snapshot()
assert c.post("/api/v1/demo/enable", headers=rt, json={"confirm": True}).status_code == 403
assert c.post("/api/v1/demo/enable", headers=adm, json={"confirm": False}).status_code == 422
assert c.get("/api/v1/demo/status").json() == {"active": False}
new = c.post("/api/v1/demo/enable", headers=adm, json={"confirm": True})
assert new.status_code == 200, new.text
dadm = H(new.json()["access_token"])
assert c.get("/api/v1/demo/status").json() == {"active": True}
me = c.get("/api/v1/me", headers=dadm).json()
assert me["demo_mode"] and me["is_admin"] and me["username"] == "admin"
# прежний боевой токен админа больше не годится — обновление выдаёт демо-сессию
assert c.get("/api/v1/me", headers=adm).status_code == 401
ref = c.post("/api/v1/auth/refresh", json={"refresh_token": tk["refresh_token"]}).json()
assert c.get("/api/v1/me", headers=H(ref["access_token"])).json()["demo_mode"] is True
# ГЛАВНОЕ: остальные работают в боевой БД, как будто демо нет
assert c.get("/api/v1/me", headers=rt).json()["demo_mode"] is False
assert "БОЕВОЙ-СТАНОК" in [e["name"] for e in c.get("/api/v1/equipment", headers=rt).json()]
assert c.post("/api/v1/auth/login", json={"username": "realtech_dm", "password": "Pass1234567"}).status_code == 200
names = [e["name"] for e in c.get("/api/v1/equipment", headers=dadm).json()]
assert len(names) >= 8 and "БОЕВОЙ-СТАНОК" not in names
info = c.get("/api/v1/demo", headers=dadm).json()
assert info["active"] and info["in_demo"] and any(a["role"] == "Модератор" for a in info["accounts"])
assert {u["username"]: u["in_demo"] for u in info["users"]}["realtech_dm"] is False
# демо-учётная запись входит по паролю demo1234, обновляет сессию
tech = [a for a in info["accounts"] if a["role"] == "Техник" and a["on_shift"]][0]
dl = c.post("/api/v1/auth/login", json={"username": tech["username"], "password": info["password"]})
assert dl.status_code == 200 and c.get("/api/v1/me", headers=H(dl.json()["access_token"])).json()["demo_mode"]
dref = c.post("/api/v1/auth/refresh", json={"refresh_token": dl.json()["refresh_token"]})
assert dref.status_code == 200 and c.get("/api/v1/me", headers=H(dref.json()["access_token"])).json()["username"] == tech["username"]
imp = c.post("/api/v1/demo/impersonate", headers=dadm, json={"user_id": tech["id"]}).json()
assert c.get("/api/v1/me", headers=H(imp["access_token"])).json()["username"] == tech["username"]
assert c.post("/api/v1/demo/destroy", headers=H(imp["access_token"])).status_code == 403
now = datetime.now()
rep = c.get("/api/v1/reports/completed-orders", headers=dadm, params={"from": (now - timedelta(days=15)).isoformat(), "to": (now + timedelta(days=1)).isoformat()}).json()
assert rep["total"] > 20
c.post("/api/v1/equipment", headers=dadm, json={"name": "демо-правка"})
assert c.post("/api/v1/backups", headers=dadm, json={}).status_code == 409 # копия — только боевой БД
assert real_snapshot() == before # боевая БД не тронута демо-правками
# администратор включает демо другому пользователю и выключает
assert c.post(f"/api/v1/demo/users/{rt_user['id']}", headers=rt, json={"on": True}).status_code == 403
assert c.post(f"/api/v1/demo/users/{rt_user['id']}", headers=dadm, json={"on": True}).json()["in_demo"] is True
assert c.get("/api/v1/me", headers=rt).status_code == 401
rl = c.post("/api/v1/auth/login", json={"username": "realtech_dm", "password": "Pass1234567"}).json()
rtd = H(rl["access_token"])
assert c.get("/api/v1/me", headers=rtd).json()["demo_mode"] is True
assert "БОЕВОЙ-СТАНОК" not in [e["name"] for e in c.get("/api/v1/equipment", headers=rtd).json()]
assert c.post(f"/api/v1/demo/users/{rt_user['id']}", headers=dadm, json={"on": False}).json()["in_demo"] is False
assert c.get("/api/v1/me", headers=rtd).status_code == 401
back = c.post("/api/v1/auth/refresh", json={"refresh_token": rl["refresh_token"]}).json()
assert c.get("/api/v1/me", headers=H(back["access_token"])).json()["demo_mode"] is False
# перезапуск сервера во время демо: демо-БД подключается снова
dbm.set_demo_engine(None).dispose() # как при перезапуске: соединения закрыты
mode.State.active = False
demo.activate_on_startup()
assert mode.State.active and dbm.demo_engine is not None
assert c.get("/api/v1/me", headers=dadm).status_code == 200
# пересоздание: старые демо-токены недействительны, refresh выдаёт новые
assert c.post("/api/v1/demo/reset", headers=dadm).status_code == 200
assert c.get("/api/v1/me", headers=dadm).status_code == 401
ref = c.post("/api/v1/auth/refresh", json={"refresh_token": new.json()["refresh_token"]}).json()
dadm = H(ref["access_token"])
assert "демо-правка" not in [e["name"] for e in c.get("/api/v1/equipment", headers=dadm).json()]
assert len(list(data_dir.glob("demo_*.db*")) + list(data_dir.glob("demo_photos_*"))) == 2
# сам выходит из демо, затем удаляет демо-данные
off = c.post("/api/v1/demo/leave", headers=dadm)
assert off.status_code == 200, off.text
backh = H(off.json()["access_token"])
assert c.get("/api/v1/me", headers=backh).json()["demo_mode"] is False
assert sorted(e["name"] for e in c.get("/api/v1/equipment", headers=backh).json()) == real_names
assert c.post("/api/v1/demo/destroy", headers=backh).status_code == 200
assert c.get("/api/v1/demo/status").json() == {"active": False}
assert list(data_dir.glob("demo_*.db*")) + list(data_dir.glob("demo_photos_*")) == []
assert c.post("/api/v1/auth/login", json={"username": tech["username"], "password": "demo1234"}).status_code == 401
assert real_snapshot()["equipment"] == before["equipment"]
+75
View File
@@ -0,0 +1,75 @@
"""Отчёт по сотрудникам, трудоёмкость задач, возвраты нарядов, фильтры вкладки «Задачи»."""
import os
import tempfile
from datetime import timedelta
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "pp.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
def test_people_report_and_work_filters():
with TestClient(app) as c:
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
eng_pos = c.post(f"{V1}/positions", headers=adm, json={"name": "PP-Инженер", "kind": "engineer"}).json()
dep = c.post(f"{V1}/departments", headers=adm, json={"name": "PP-Служба"}).json()
a = c.post(f"{V1}/users", headers=adm, json={"username": "pp_a", "full_name": "Альфа Работник", "password": PW, "department_id": dep["id"]}).json()
c.post(f"{V1}/users", headers=adm, json={"username": "pp_e", "full_name": "Инж Проверяющий", "password": PW, "position_id": eng_pos["id"]})
ta, te = login("pp_a"), login("pp_e")
# задача с нормой трудоёмкости и задача без нормы (фактическое время)
t1 = c.post(f"{V1}/tasks", headers=te, json={"title": "Норма 2.5 ч", "assignee_id": a["id"], "labor_hours": 2.5}).json()
assert t1["labor_hours"] == 2.5
for s, cm in (("in_progress", ""), ("done", "ok")):
c.post(f"{V1}/tasks/{t1['id']}/status", headers=ta, json={"status": s, "comment": cm})
t2 = c.post(f"{V1}/tasks", headers=te, json={"title": "Отменённая", "assignee_id": a["id"]}).json()
c.post(f"{V1}/tasks/{t2['id']}/status", headers=ta, json={"status": "paused", "comment": "жду"})
c.post(f"{V1}/tasks/{t2['id']}/status", headers=ta, json={"status": "cancelled", "comment": "не нужно"})
# фильтры вкладки «Задачи»: поставленные мной
mine_set = c.get(f"{V1}/tasks", headers=te, params={"created_by_me": True}).json()
assert {x["title"] for x in mine_set} >= {"Норма 2.5 ч", "Отменённая"}
# наряд: возврат с проверки и отклонение от нормы
cl = c.post(f"{V1}/checklists", headers=adm, json={"name": "PP-чек", "items": [{"text": "Давление", "answer_type": "number", "min_value": 1, "max_value": 2}]}).json()
o = c.post(f"{V1}/orders", headers=te, json={"title": "PP-наряд", "checklist_id": cl["id"], "assignee_user_id": a["id"],
"reviewer_user_id": c.get(f"{V1}/me", headers=te).json()["id"]}).json()
item = c.get(f"{V1}/orders/{o['id']}", headers=ta).json()["items"][0]
c.put(f"{V1}/orders/{o['id']}/items/{item['id']}", headers=ta, json={"value": "5"})
st = c.post(f"{V1}/orders/{o['id']}/submit", headers=ta).json()["status"]
if st == "review":
assert c.post(f"{V1}/orders/{o['id']}/review", headers=te, json={"approve": False, "comment": "Переделать"}).status_code == 200
c.post(f"{V1}/orders/{o['id']}/submit", headers=ta)
c.post(f"{V1}/orders/{o['id']}/review", headers=te, json={"approve": True})
now = config.now()
q = {"from": (now - timedelta(days=1)).isoformat(), "to": (now + timedelta(days=1)).isoformat()}
assert c.get(f"{V1}/reports/people", headers=ta, params=q).status_code == 403
rep = c.get(f"{V1}/reports/people", headers=te, params={**q, "department_id": dep["id"]}).json()
assert [r["name"] for r in rep["rows"]] == ["Альфа Работник"]
r = rep["rows"][0]
assert r["tasks_done"] == 1 and r["tasks_cancelled"] == 1 and r["tasks_paused"] == 1 and r["orders_done"] == 1
assert r["deviations"] == 1 and r["hours"] >= 2.5 and r["on_time_pct"] is not None
day = now.date().isoformat()
assert r["calendar"][day]["n"] == 2 and r["calendar"][day]["h"] >= 2.5
assert len(rep["days"]) >= 2
pdf = c.get(f"{V1}/reports/people", headers=te, params={**q, "format": "pdf"})
assert pdf.status_code == 200 and pdf.content[:5] == b"%PDF-"
csv = c.get(f"{V1}/reports/people", headers=te, params={**q, "format": "csv_calendar"}).content.decode("utf-8-sig")
assert "Альфа Работник" in csv and "Итого ч" in csv
# фильтры инцидентов для вкладки «Задачи»
eq = c.post(f"{V1}/equipment", headers=adm, json={"name": "PP-Станок"}).json()
c.post(f"{V1}/incidents", headers=ta, json={"equipment_id": eq["id"], "title": "PP-авария"})
assert [i["title"] for i in c.get(f"{V1}/incidents", headers=ta, params={"mine": True}).json()] == ["PP-авария"]
assert c.get(f"{V1}/incidents", headers=ta, params={"q": "pp-стан"}).json()[0]["title"] == "PP-авария"
assert all(i["source"] == "staff" for i in c.get(f"{V1}/incidents", headers=ta, params={"source": "staff"}).json())
+333
View File
@@ -0,0 +1,333 @@
"""Портал: инвентарь и склады, заявки на инструмент, инциденты, суточные задания, карта, карточка сотрудника, отчёт."""
import os
import tempfile
from datetime import timedelta
import pytest
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "portal.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 64
PW = "Pass1234567"
@pytest.fixture(scope="module")
def env():
with TestClient(app) as c:
def login(u, p=PW):
r = c.post(f"{V1}/auth/login", json={"username": u, "password": p})
assert r.status_code == 200, r.text
return {"Authorization": "Bearer " + r.json()["access_token"]}
adm = login("admin", "admin")
tech_pos = c.post(f"{V1}/positions", headers=adm, json={"name": "П-Слесарь", "kind": "technician"}).json()
eng_pos = c.post(f"{V1}/positions", headers=adm, json={"name": "П-Инженер", "kind": "engineer"}).json()
ids = {}
for name, pos, extra in (("p_tech", tech_pos, {}), ("p_tech2", tech_pos, {}), ("p_eng", eng_pos, {}),
("p_store", tech_pos, {"is_storekeeper": True})):
r = c.post(f"{V1}/users", headers=adm, json={"username": name, "password": PW, "full_name": name.upper(),
"position_id": pos["id"], **extra})
assert r.status_code == 201, r.text
ids[name] = r.json()["id"]
eq = c.post(f"{V1}/equipment", headers=adm, json={"name": "П-Насос", "icon": "builtin:pump"}).json()
unit = c.post(f"{V1}/equipment/{eq['id']}/service-units", headers=adm,
json={"name": "Подшипник", "mode": "hours", "interval_hours": 100}).json()
yield {"c": c, "adm": adm, "tech": login("p_tech"), "tech2": login("p_tech2"), "eng": login("p_eng"),
"store": login("p_store"), "ids": ids, "eq": eq, "unit": unit}
def test_inventory_roles_and_moves(env):
c, store, tech, adm = env["c"], env["store"], env["tech"], env["adm"]
tid = env["ids"]["p_tech"]
assert c.get(f"{V1}/me", headers=store).json()["is_storekeeper"] is True
# сотрудник не может править каталог и склады
assert c.post(f"{V1}/warehouses", headers=tech, json={"name": "Х"}).status_code == 403
assert c.post(f"{V1}/inventory/items", headers=tech, json={"name": "Х"}).status_code == 403
w1 = c.post(f"{V1}/warehouses", headers=store, json={"name": "Склад №1"}).json()
w2 = c.post(f"{V1}/warehouses", headers=store, json={"name": "Склад №2"}).json()
drill = c.post(f"{V1}/inventory/items", headers=store, json={"name": "Дрель", "category": "Электроинструмент",
"inventory_no": "ИН-1"}).json()
keys = c.post(f"{V1}/inventory/items", headers=store, json={"name": "Набор ключей"}).json()
mv = lambda body, h=store: c.post(f"{V1}/inventory/moves", headers=h, json=body)
assert mv({"kind": "receive", "item_id": drill["id"], "qty": 2, "to": {"warehouse_id": w1["id"]}}).status_code == 201
assert mv({"kind": "receive", "item_id": keys["id"], "qty": 5, "to": {"warehouse_id": w1["id"]}}).status_code == 201
# сотрудник не может выдавать
assert mv({"kind": "move", "item_id": drill["id"], "from": {"warehouse_id": w1["id"]}, "to": {"user_id": tid}}, tech).status_code == 403
# выдача лично сотруднику
r = mv({"kind": "move", "item_id": drill["id"], "qty": 1, "from": {"warehouse_id": w1["id"]}, "to": {"user_id": tid}})
assert r.status_code == 201, r.text
# нельзя выдать больше, чем есть
assert mv({"kind": "move", "item_id": drill["id"], "qty": 5, "from": {"warehouse_id": w1["id"]}, "to": {"user_id": tid}}).status_code == 422
# перемещение между складами, списание
assert mv({"kind": "move", "item_id": keys["id"], "qty": 2, "from": {"warehouse_id": w1["id"]}, "to": {"warehouse_id": w2["id"]}}).status_code == 201
assert mv({"kind": "writeoff", "item_id": keys["id"], "qty": 1, "from": {"warehouse_id": w2["id"]}, "note": "сломан"}).status_code == 201
# «откуда» должно быть ровно одно
assert mv({"kind": "move", "item_id": keys["id"], "from": {"warehouse_id": w1["id"], "user_id": tid}, "to": {"warehouse_id": w2["id"]}}).status_code == 422
# сотрудник видит только своё
mine = c.get(f"{V1}/me/inventory", headers=tech).json()
assert [(x["item_name"], x["qty"]) for x in mine] == [("Дрель", 1)]
assert c.get(f"{V1}/inventory/stock", headers=tech, params={"warehouse_id": w1["id"]}).status_code == 403
assert c.get(f"{V1}/inventory/stock", headers=tech).json() == mine
st = {x["item_name"]: x["qty"] for x in c.get(f"{V1}/inventory/stock", headers=store, params={"warehouse_id": w1["id"]}).json()}
assert st == {"Дрель": 1, "Набор ключей": 3}
items = {x["name"]: x for x in c.get(f"{V1}/inventory/items", headers=store).json()}
assert items["Дрель"]["in_stock"] == 1 and items["Дрель"]["issued"] == 1
whs = {x["name"]: x for x in c.get(f"{V1}/warehouses", headers=tech).json()}
assert whs["Склад №2"]["total_qty"] == 1
moves = c.get(f"{V1}/inventory/moves", headers=store, params={"item_id": drill["id"]}).json()
assert [m["kind"] for m in moves] == ["issue", "receive"]
assert [m["kind"] for m in c.get(f"{V1}/inventory/moves", headers=tech).json()] == ["issue"]
# возврат на склад
assert mv({"kind": "move", "item_id": drill["id"], "from": {"user_id": tid}, "to": {"warehouse_id": w1["id"]}}).status_code == 201
assert c.get(f"{V1}/me/inventory", headers=tech).json() == []
# склад с инвентарём не удаляется; зона на карте
assert c.delete(f"{V1}/warehouses/{w1['id']}", headers=store).status_code == 409
assert c.put(f"{V1}/warehouses/{w1['id']}/zone", headers=store, json={"zone": {"x": 10, "y": 10, "w": 20, "h": 15}}).status_code == 200
assert c.put(f"{V1}/warehouses/{w1['id']}/zone", headers=store, json={"zone": {"x": 90, "y": 10, "w": 20, "h": 15}}).status_code == 422
assert c.put(f"{V1}/warehouses/{w1['id']}/zone", headers=tech, json={"zone": None}).status_code == 403
# обычный администратор тоже может всё
assert c.post(f"{V1}/warehouses", headers=adm, json={"name": "Склад админа"}).status_code == 201
def test_tool_requests(env):
c, tech, tech2, store = env["c"], env["tech"], env["tech2"], env["store"]
bad = c.post(f"{V1}/tool-requests", headers=tech, json={"what": "Мультиметр", "why": "Старый сломан", "link": "javascript:alert(1)"})
assert bad.status_code == 422
assert c.post(f"{V1}/tool-requests", headers=tech, json={"what": "Мультиметр"}).status_code == 422 # «зачем» обязательно
r = c.post(f"{V1}/tool-requests", headers=tech, json={"what": "Мультиметр Fluke", "why": "Старый сломан",
"link": "https://example.com/item/1", "qty": 1}).json()
assert r["status"] == "new" and r["author_name"] == "P_TECH"
c.post(f"{V1}/tool-requests", headers=tech2, json={"what": "Перчатки", "why": "Износились"})
assert [x["what"] for x in c.get(f"{V1}/tool-requests", headers=tech).json()] == ["Мультиметр Fluke"]
assert len(c.get(f"{V1}/tool-requests", headers=store).json()) >= 2
assert c.post(f"{V1}/tool-requests/{r['id']}/answer", headers=tech, json={"status": "approved"}).status_code == 403
assert c.post(f"{V1}/tool-requests/{r['id']}/answer", headers=store, json={"status": "rejected"}).status_code == 422
a = c.post(f"{V1}/tool-requests/{r['id']}/answer", headers=store, json={"status": "ordered", "answer": "Заказан"}).json()
assert a["status"] == "ordered" and a["handled_by_name"] == "P_STORE"
assert c.delete(f"{V1}/tool-requests/{r['id']}", headers=tech).status_code == 403 # уже не новая
def test_incidents_affect_state(env):
c, tech, eng, adm, eq, unit = env["c"], env["tech"], env["eng"], env["adm"], env["eq"], env["unit"]
c.post(f"{V1}/equipment/{eq['id']}/meter", headers=eng, json={"hours": 95}) # узел «скоро» (осталось 5 из 100)
h = c.get(f"{V1}/equipment-health", headers=tech).json()[str(eq["id"])]
assert h["state"] == "soon" and h["soon"] == 1
other = c.post(f"{V1}/equipment", headers=adm, json={"name": "П-Другая"}).json()
other_unit = c.post(f"{V1}/equipment/{other['id']}/service-units", headers=adm, json={"name": "X", "mode": "hours", "interval_hours": 10}).json()
assert c.post(f"{V1}/incidents", headers=tech, json={"equipment_id": eq["id"], "title": "Течь",
"units": [{"unit_id": other_unit["id"]}]}).status_code == 422
r = c.post(f"{V1}/incidents", headers=tech, json={"equipment_id": eq["id"], "title": "Заклинил подшипник",
"description": "Шум, нагрев", "units": [{"unit_id": unit["id"], "replaced": True}],
"replacement_note": "Поставлен новый"})
assert r.status_code == 201, r.text
inc = r.json()
assert inc["replaced"] is True and inc["units"][0]["unit_name"] == "Подшипник" and inc["status"] == "open"
# замена сбросила узел, открытый инцидент — «Авария»
u = c.get(f"{V1}/equipment/{eq['id']}/service-units", headers=tech).json()[0]
assert u["state"] == "ok" and u["used_hours"] == 0
assert c.get(f"{V1}/service-units/{unit['id']}/events", headers=tech).json()[0]["kind"] == "incident"
m = {x["id"]: x for x in c.get(f"{V1}/map", headers=tech).json()["equipment"]}
assert m[eq["id"]]["state"] == "alarm" and m[eq["id"]]["open_incidents"] == 1
assert c.post(f"{V1}/incidents/{inc['id']}/close", headers=env["tech2"], json={}).status_code == 403
assert c.post(f"{V1}/incidents/{inc['id']}/close", headers=tech, json={"note": "Устранено"}).json()["status"] == "closed"
assert c.get(f"{V1}/equipment-health", headers=tech).json()[str(eq["id"])]["state"] == "ok"
assert len(c.get(f"{V1}/incidents", headers=tech, params={"equipment_id": eq["id"]}).json()) == 1
def test_daily_tasks(env):
c, tech, tech2, eng, eq, unit = env["c"], env["tech"], env["tech2"], env["eng"], env["eq"], env["unit"]
ids = env["ids"]
# техник не может ставить задания другим, но может себе
assert c.post(f"{V1}/tasks", headers=tech, json={"title": "Чужое", "assignee_id": ids["p_tech2"]}).status_code == 403
own = c.post(f"{V1}/tasks", headers=tech, json={"title": "Себе задание"}).json()
assert own["assignee_id"] == ids["p_tech"] and own["status"] == "assigned"
t = c.post(f"{V1}/tasks", headers=eng, json={"title": "Проверить насос", "assignee_id": ids["p_tech"],
"equipment_id": eq["id"], "unit_id": unit["id"]}).json()
assert t["equipment_name"] == "П-Насос" and t["unit_name"] == "Подшипник"
st = lambda s, cm="", h=tech: c.post(f"{V1}/tasks/{t['id']}/status", headers=h, json={"status": s, "comment": cm})
assert st("in_progress").status_code == 200
assert st("paused").status_code == 422 # без комментария нельзя
assert st("paused", "Жду запчасть").status_code == 200
assert st("done").status_code == 422
assert st("in_progress").status_code == 200
# чужой техник не видит задание
assert c.get(f"{V1}/tasks/{t['id']}", headers=tech2).status_code == 404
# фото
r = c.post(f"{V1}/tasks/{t['id']}/photos", headers=tech, content=PNG)
assert r.status_code == 201 and len(r.json()["photos"]) == 1
assert c.get(f"{V1}/task-photos/{r.json()['photos'][0]}", headers=eng).status_code == 200
assert c.post(f"{V1}/tasks/{t['id']}/photos", headers=tech, content=b"not an image").status_code == 415
done = st("done", "Подшипник смазан").json()
assert done["status"] == "done" and done["finished_at"] and done["last_comment"] == "Подшипник смазан"
assert [e["to_status"] for e in done["events"]] == ["assigned", "in_progress", "paused", "in_progress", "done"]
assert st("in_progress").status_code == 403 # завершённое техник уже не меняет
assert st("in_progress", "x", eng).status_code == 422 # из «выполнен» — только вернуть в «назначен»
assert st("assigned", "Переделать").status_code == 403 # техник не возвращает завершённое
assert st("assigned", "Переделать", eng).status_code == 200
mine = c.get(f"{V1}/tasks", headers=tech, params={"mine": True}).json()
assert {x["title"] for x in mine} >= {"Себе задание", "Проверить насос"}
assert all(x["assignee_id"] == ids["p_tech"] for x in c.get(f"{V1}/tasks", headers=tech).json())
def test_icons_map_card(env):
c, adm, tech, store, eng, eq = env["c"], env["adm"], env["tech"], env["store"], env["eng"], env["eq"]
icons = c.get(f"{V1}/icons", headers=tech).json()
assert len(icons) >= 12 and all(i["id"].startswith("builtin:") for i in icons)
assert c.get(icons[0]["url"]).status_code == 200
assert c.patch(f"{V1}/equipment/{eq['id']}", headers=adm, json={"icon": "file:../../etc/passwd"}).status_code == 422
assert c.patch(f"{V1}/equipment/{eq['id']}", headers=adm, json={"icon": "builtin:motor"}).json()["icon"] == "builtin:motor"
r = c.put(f"{V1}/equipment/{eq['id']}/icon", headers=eng, content=PNG)
assert r.status_code == 200 and r.json()["icon"].startswith("file:")
assert c.get(f"{V1}/equipment/{eq['id']}/icon", headers=tech).content == PNG
# карта: картинку и иконки ставит админ, зоны складов — кладовщик
assert c.put(f"{V1}/map/image", headers=store, content=PNG).status_code == 403
assert c.put(f"{V1}/map/image", headers=adm, content=PNG).status_code == 204
assert c.get(f"{V1}/map/image", headers=tech).content == PNG
assert c.put(f"{V1}/equipment/{eq['id']}/map", headers=store, json={"x": 10, "y": 20}).status_code == 403
assert c.put(f"{V1}/equipment/{eq['id']}/map", headers=adm, json={"x": 10, "y": None}).status_code == 422
assert c.put(f"{V1}/equipment/{eq['id']}/map", headers=adm, json={"x": 10.5, "y": 20}).json()["map_x"] == 10.5
m = c.get(f"{V1}/map", headers=store).json()
assert m["has_image"] and m["can_edit_warehouses"] and not m["can_edit_equipment"]
assert any(e["id"] == eq["id"] and e["x"] == 10.5 for e in m["equipment"])
# карточка сотрудника
tid = env["ids"]["p_tech"]
assert c.patch(f"{V1}/users/{tid}/profile", headers=tech, json={"skills": "Сварка, электрика", "phone": "+7 900"}).status_code == 200
assert c.patch(f"{V1}/users/{env['ids']['p_tech2']}/profile", headers=tech, json={"skills": "x"}).status_code == 403
assert c.put(f"{V1}/users/{tid}/photo", headers=tech, content=PNG).json()["has_photo"] is True
card = c.get(f"{V1}/users/{tid}/card", headers=env["tech2"]).json()
assert card["user"]["skills"] == "Сварка, электрика" and card["user"]["position"]["name"] == "П-Слесарь"
assert any(t["title"] == "Проверить насос" for t in card["tasks"])
assert card["inventory"] is None and card["can_edit"] is False # чужой инвентарь коллеге не показываем
assert c.get(f"{V1}/users/{tid}/card", headers=store).json()["inventory"] == []
assert c.get(f"{V1}/users/{tid}/photo", headers=env["tech2"]).content == PNG
def test_execution_report(env):
c, adm, eng, tech, eq = env["c"], env["adm"], env["eng"], env["tech"], env["eq"]
cl = c.post(f"{V1}/checklists", headers=adm, json={"name": "П-Осмотр", "items": [
{"text": "Давление", "answer_type": "number", "min_value": 1, "max_value": 5, "unit": "бар"}]}).json()
o = c.post(f"{V1}/orders", headers=eng, json={"title": "Осмотр насоса", "checklist_id": cl["id"],
"equipment_id": eq["id"], "assignee_user_id": env["ids"]["p_tech"]}).json()
item = c.get(f"{V1}/orders/{o['id']}", headers=tech).json()["items"][0]
c.put(f"{V1}/orders/{o['id']}/items/{item['id']}", headers=tech, json={"value": "9"})
assert c.post(f"{V1}/orders/{o['id']}/submit", headers=tech).json()["status"] == "completed"
o2 = c.post(f"{V1}/orders", headers=eng, json={"title": "Второй осмотр", "checklist_id": cl["id"], "equipment_id": eq["id"]}).json()
c.post(f"{V1}/orders/{o2['id']}/cancel", headers=eng)
now = config.now()
q = {"from": (now - timedelta(days=1)).isoformat(), "to": (now + timedelta(days=1)).isoformat(), "equipment_id": eq["id"]}
assert c.get(f"{V1}/reports/execution", headers=tech, params=q).status_code == 403
rep = c.get(f"{V1}/reports/execution", headers=eng, params=q).json()
row = rep["rows"][0]
assert row["equipment"] == "П-Насос"
assert row["cats"]["inspection"] == {"planned": 2, "done": 1, "percent": 50}
assert row["cats"]["tasks"]["planned"] >= 1
texts = [d["text"] for d in row["deviations"]]
assert any("Давление: 9 бар" in t for t in texts) and any("Наряд отменён" in t for t in texts)
assert any("Жду запчасть" in t for t in texts)
pdf = c.get(f"{V1}/reports/execution", headers=eng, params={**q, "format": "pdf"})
assert pdf.status_code == 200 and pdf.content[:5] == b"%PDF-"
csv = c.get(f"{V1}/reports/execution", headers=eng, params={**q, "format": "csv"})
assert csv.status_code == 200 and "Суточные осмотры" in csv.content.decode("utf-8-sig")
def test_task_transfer(env):
c, tech, tech2, eng, ids = env["c"], env["tech"], env["tech2"], env["eng"], env["ids"]
t = c.post(f"{V1}/tasks", headers=eng, json={"title": "Передаваемое", "assignee_id": ids["p_tech"]}).json()
assert t["can_transfer"] is False or True
c.post(f"{V1}/tasks/{t['id']}/status", headers=tech, json={"status": "in_progress"})
tr = lambda h, to, cm="Занят на аварии": c.post(f"{V1}/tasks/{t['id']}/transfer", headers=h, json={"assignee_id": to, "comment": cm})
assert tr(tech2, ids["p_tech2"]).status_code == 404 # чужое задание не видно
assert tr(tech, ids["p_tech2"], "").status_code == 422 # причина обязательна
r = tr(tech, ids["p_tech2"]) # исполнитель передаёт
assert r.status_code == 200, r.text
d = r.json()
assert d["assignee_id"] == ids["p_tech2"] and d["status"] == "assigned"
ev = d["events"][-1]
assert ev["kind"] == "transfer" and ev["from_user_name"] == "P_TECH" and ev["to_user_name"] == "P_TECH2"
assert tr(tech2, ids["p_tech2"]).status_code == 409
assert tr(eng, ids["p_tech"]).status_code == 200 # заказчик (поставивший) возвращает
c.post(f"{V1}/tasks/{t['id']}/status", headers=tech, json={"status": "done", "comment": "ok"})
assert tr(eng, ids["p_tech2"]).status_code == 403 # завершённое не передаётся
def test_public_and_external_incidents(env):
c, adm, tech, eq = env["c"], env["adm"], env["tech"], env["eq"]
loc = c.post(f"{V1}/locations", headers=adm, json={"name": "П-Цех"}).json()
sub = c.post(f"{V1}/locations", headers=adm, json={"name": "П-Участок", "parent_id": loc["id"]}).json()
other = c.post(f"{V1}/locations", headers=adm, json={"name": "П-Склад"}).json()
c.patch(f"{V1}/equipment/{eq['id']}", headers=adm, json={"location_id": sub["id"], "inventory_no": "П-ИНВ-1"})
o = c.get(f"{V1}/public/report-options").json() # без входа
assert o["enabled"] and any(e["id"] == eq["id"] and e["location_id"] == sub["id"] for e in o["equipment"])
base = {"location_id": loc["id"], "equipment_id": eq["id"], "description": "Сильная течь масла под машиной",
"reporter_name": "Подрядчик Петров", "reporter_contact": "+7 900 123-45-67"}
assert c.post(f"{V1}/public/incidents", json={**base, "reporter_contact": ""}).status_code == 422
assert c.post(f"{V1}/public/incidents", json={**base, "description": ""}).status_code == 422
assert c.post(f"{V1}/public/incidents", json={**base, "location_id": other["id"]}).status_code == 422 # машина не в этом месте
r = c.post(f"{V1}/public/incidents", json=base) # родительское место включает вложенное
assert r.status_code == 201 and r.json()["number"]
inc = c.get(f"{V1}/incidents/{r.json()['number']}", headers=tech).json()
assert inc["source"] == "public" and inc["reporter_contact"] == "+7 900 123-45-67" and inc["title"].startswith("Сильная течь")
assert c.get("/report").status_code == 200
# выключение формы
c.put(f"{V1}/settings", headers=adm, json={"public_incidents": False})
assert c.get(f"{V1}/public/report-options").json()["enabled"] is False
assert c.post(f"{V1}/public/incidents", json=base).status_code == 403
c.put(f"{V1}/settings", headers=adm, json={"public_incidents": True})
# внешняя система по ключу
assert c.post(f"{V1}/api-keys", headers=tech, json={"name": "x"}).status_code == 403
k = c.post(f"{V1}/api-keys", headers=adm, json={"name": "SCADA"}).json()
assert k["key"].startswith("cl_")
H = {"X-API-Key": k["key"]}
body = {"inventory_no": "П-ИНВ-1", "title": "Перегрев двигателя", "external_id": "SCADA-77"}
assert c.post(f"{V1}/external/incidents", json=body).status_code == 401
assert c.post(f"{V1}/external/incidents", headers={"X-API-Key": "cl_wrong"}, json=body).status_code == 401
r1 = c.post(f"{V1}/external/incidents", headers=H, json=body)
assert r1.status_code == 201 and r1.json()["source"] == "api" and r1.json()["reporter_name"] == "SCADA"
assert c.post(f"{V1}/external/incidents", headers=H, json=body).json()["id"] == r1.json()["id"] # повтор — без дубликата
assert c.post(f"{V1}/external/incidents", headers=H, json={"inventory_no": "нет", "title": "x1"}).status_code == 404
assert any(e["inventory_no"] == "П-ИНВ-1" for e in c.get(f"{V1}/external/equipment", headers=H).json())
assert c.get(f"{V1}/api-keys", headers=adm).json()[0]["last_used_at"]
c.delete(f"{V1}/api-keys/{k['id']}", headers=adm)
assert c.post(f"{V1}/external/incidents", headers=H, json=body).status_code == 401
def test_equipment_history(env):
c, tech, eq = env["c"], env["tech"], env["eq"]
h = c.get(f"{V1}/equipment/{eq['id']}/history", headers=tech).json()
kinds = {r["type"] for r in h["rows"]}
assert {"incident", "service", "meter", "order", "task"} <= kinds
assert h["rows"] == sorted(h["rows"], key=lambda r: r["at"], reverse=True)
only = c.get(f"{V1}/equipment/{eq['id']}/history", headers=tech, params={"types": "incident"}).json()
assert only["rows"] and all(r["type"] == "incident" for r in only["rows"])
assert c.get(f"{V1}/equipment/{eq['id']}/history", headers=tech, params={"types": ""}).json()["total"] == 0
srch = c.get(f"{V1}/equipment/{eq['id']}/history", headers=tech, params={"q": "заклинил"}).json()
assert srch["rows"] and all("аклинил" in r["title"] + r["text"] for r in srch["rows"])
old = c.get(f"{V1}/equipment/{eq['id']}/history", headers=tech, params={"to": "2000-01-01"}).json()
assert old["total"] == 0
assert any("заменён при отказе" in r["title"] for r in h["rows"])
def test_departments(env):
c, adm, tech, ids = env["c"], env["adm"], env["tech"], env["ids"]
assert c.post(f"{V1}/departments", headers=tech, json={"name": "Х"}).status_code == 403
top = c.post(f"{V1}/departments", headers=adm, json={"name": "П-Служба ГИ"}).json()
mech = c.post(f"{V1}/departments", headers=adm, json={"name": "П-Мехслужба", "parent_id": top["id"], "head_user_id": ids["p_eng"]}).json()
assert c.patch(f"{V1}/departments/{top['id']}", headers=adm, json={"parent_id": mech["id"]}).status_code == 422 # цикл
assert c.post(f"{V1}/departments", headers=adm, json={"name": "П-Мехслужба"}).status_code == 409 # дубль
assert c.patch(f"{V1}/users/{ids['p_tech']}", headers=adm, json={"department_id": 99999}).status_code == 422
u = c.patch(f"{V1}/users/{ids['p_tech']}", headers=adm, json={"department_id": mech["id"]}).json()
assert u["department"]["name"] == "П-Мехслужба"
deps = {d["name"]: d for d in c.get(f"{V1}/departments", headers=tech).json()}
assert deps["П-Мехслужба"]["members"] == 1 and deps["П-Мехслужба"]["parent_id"] == top["id"]
assert c.get(f"{V1}/users/{ids['p_tech']}/card", headers=tech).json()["user"]["department"]["id"] == mech["id"]
assert c.delete(f"{V1}/departments/{mech['id']}", headers=adm).status_code == 204
assert c.get(f"{V1}/me", headers=tech).json()["department_id"] is None
+70
View File
@@ -0,0 +1,70 @@
"""Закрепление оборудования за отделами и отдельная карта кранов."""
import os
import tempfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "sc.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())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
def test_equipment_by_department_and_crane_map():
with TestClient(app) as c:
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
top = c.post(f"{V1}/departments", headers=adm, json={"name": "SC-Главный механик"}).json()
cr = c.post(f"{V1}/departments", headers=adm, json={"name": "SC-Крановая служба", "parent_id": top["id"]}).json()
mc = c.post(f"{V1}/departments", headers=adm, json={"name": "SC-Станочный участок", "parent_id": top["id"]}).json()
for un, dep in (("sc_crane", cr), ("sc_mach", mc), ("sc_chief", top)):
assert c.post(f"{V1}/users", headers=adm, json={"username": un, "full_name": un, "password": PW, "department_id": dep["id"]}).status_code == 201
crane = c.post(f"{V1}/equipment", headers=adm, json={"name": "SC-Кран мостовой", "department_id": cr["id"], "is_crane": True}).json()
lathe = c.post(f"{V1}/equipment", headers=adm, json={"name": "SC-Станок", "department_id": mc["id"]}).json()
common = c.post(f"{V1}/equipment", headers=adm, json={"name": "SC-Общий щит"}).json()
assert crane["is_crane"] and crane["department_id"] == cr["id"]
for e, x in ((crane, 10), (lathe, 20), (common, 30)):
c.put(f"{V1}/equipment/{e['id']}/map", headers=adm, json={"x": x, "y": x})
tc, tm, tch = login("sc_crane"), login("sc_mach"), login("sc_chief")
names = lambda h: {e["name"] for e in c.get(f"{V1}/equipment", headers=h).json() if e["name"].startswith("SC-")}
assert names(tc) == {"SC-Кран мостовой", "SC-Общий щит"}
assert names(tm) == {"SC-Станок", "SC-Общий щит"}
assert names(tch) == names(adm) == {"SC-Кран мостовой", "SC-Станок", "SC-Общий щит"} # вышестоящий отдел видит вложенные
# инцидент по станку крановщик не видит ни в списке, ни в состоянии машин
c.post(f"{V1}/incidents", headers=tm, json={"equipment_id": lathe["id"], "title": "SC-поломка станка"})
assert "SC-поломка станка" not in [i["title"] for i in c.get(f"{V1}/incidents", headers=tc).json()]
assert "SC-поломка станка" in [i["title"] for i in c.get(f"{V1}/incidents", headers=tch).json()]
assert str(lathe["id"]) not in c.get(f"{V1}/equipment-health", headers=tc).json()
assert c.get(f"{V1}/equipment-health", headers=tm).json()[str(lathe["id"])]["state"] == "alarm"
# карта кранов выключена: кран на общей карте, отдельной карты нет
plant = lambda h: {e["name"] for e in c.get(f"{V1}/map", headers=h).json()["equipment"] if e["name"].startswith("SC-")}
assert plant(adm) == {"SC-Кран мостовой", "SC-Станок", "SC-Общий щит"}
assert c.get(f"{V1}/map", headers=adm, params={"kind": "crane"}).status_code == 404
assert c.get(f"{V1}/me", headers=tc).json()["crane_map"] is False
# включили: краны уходят на свою карту
assert c.put(f"{V1}/settings", headers=adm, json={"crane_map": True}).json()["crane_map"] == "1"
assert c.get(f"{V1}/me", headers=tc).json()["crane_map"] is True
assert plant(adm) == {"SC-Станок", "SC-Общий щит"}
cm = c.get(f"{V1}/map", headers=tc, params={"kind": "crane"}).json()
assert [e["name"] for e in cm["equipment"] if e["name"].startswith("SC-")] == ["SC-Кран мостовой"] and cm["own_image"] is False
assert c.get(f"{V1}/map", headers=tm, params={"kind": "crane"}).json()["equipment"] == []
assert plant(tm) == {"SC-Станок", "SC-Общий щит"}
# инциденты: краны отдельно от остального оборудования
c.post(f"{V1}/incidents", headers=tc, json={"equipment_id": crane["id"], "title": "SC-концевик"})
only = lambda **q: {i["title"] for i in c.get(f"{V1}/incidents", headers=adm, params=q).json() if i["title"].startswith("SC-")}
assert only(cranes=True) == {"SC-концевик"} and only(cranes=False) == {"SC-поломка станка"}
c.put(f"{V1}/settings", headers=adm, json={"crane_map": False})
assert c.get("/presentation.exe").status_code == 404
# удаление отдела: оборудование переходит к вышестоящему отделу
assert c.delete(f"{V1}/departments/{cr['id']}", headers=adm).status_code == 204
assert c.get(f"{V1}/equipment/{crane['id']}", headers=adm).json()["department_id"] == top["id"]
+56
View File
@@ -0,0 +1,56 @@
"""Сообщения о неисправности: машина работает, состояние «Неисправность», перевод в инцидент, публичная форма и внешний API."""
import os
import tempfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "fl.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())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
def test_fault_reports():
with TestClient(app) as c:
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
c.post(f"{V1}/users", headers=adm, json={"username": "fl_t", "full_name": "Техник Неисправностей", "password": PW})
t = login("fl_t")
loc = c.post(f"{V1}/locations", headers=adm, json={"name": "FL-Цех"}).json()
eq = c.post(f"{V1}/equipment", headers=adm, json={"name": "FL-Насос", "location_id": loc["id"]}).json()
su = c.post(f"{V1}/equipment/{eq['id']}/service-units", headers=adm, json={"name": "Подшипник", "interval_hours": 1000}).json()
# сообщение о неисправности: состояние «fault», замены нет (ресурс узла не сбрасывается)
f = c.post(f"{V1}/incidents", headers=t, json={"kind": "fault", "equipment_id": eq["id"], "title": "FL-шум",
"units": [{"unit_id": su["id"], "replaced": True}], "replaced": True}).json()
assert f["kind"] == "fault" and f["replaced"] is False and f["units"][0]["replaced"] is False
h = c.get(f"{V1}/equipment-health", headers=t).json()[str(eq["id"])]
assert h["state"] == "fault" and h["open_faults"] == 1 and h["open_incidents"] == 0
assert [i["title"] for i in c.get(f"{V1}/incidents", headers=t, params={"kind": "fault", "q": "fl-"}).json()] == ["FL-шум"]
assert c.get(f"{V1}/incidents", headers=t, params={"kind": "incident", "q": "fl-"}).json() == []
hist = c.get(f"{V1}/equipment/{eq['id']}/history", headers=t).json()["rows"]
assert any(r["title"] == "Неисправность: FL-шум" for r in hist)
# ухудшилось — перевод в инцидент: машина в «Аварии»
e = c.post(f"{V1}/incidents/{f['id']}/escalate", headers=t, json={"note": "остановили"}).json()
assert e["kind"] == "incident" and "остановили" in e["description"]
assert c.get(f"{V1}/equipment-health", headers=t).json()[str(eq["id"])]["state"] == "alarm"
assert c.post(f"{V1}/incidents/{f['id']}/escalate", headers=t, json={}).status_code == 409
c.post(f"{V1}/incidents/{f['id']}/close", headers=t, json={"note": "ok"})
assert c.get(f"{V1}/equipment-health", headers=t).json()[str(eq["id"])]["state"] in ("ok", "due", "soon")
# публичная форма и внешний API принимают kind
r = c.post(f"{V1}/public/incidents", json={"location_id": loc["id"], "equipment_id": eq["id"], "kind": "fault",
"description": "Подтекает сальник", "reporter_name": "Подрядчик", "reporter_contact": "+7 900 000"})
assert r.status_code == 201
pub = c.get(f"{V1}/incidents", headers=adm, params={"source": "public", "kind": "fault"}).json()
assert pub and pub[0]["kind"] == "fault"
key = c.post(f"{V1}/api-keys", headers=adm, json={"name": "FL-SCADA"}).json()["key"]
x = c.post(f"{V1}/external/incidents", headers={"X-API-Key": key}, json={"equipment_id": eq["id"], "kind": "fault", "title": "FL-вибрация"})
assert x.status_code == 201 and x.json()["kind"] == "fault"
assert c.post(f"{V1}/incidents", headers=t, json={"kind": "nonsense", "equipment_id": eq["id"], "title": "x1"}).status_code == 422
+52
View File
@@ -0,0 +1,52 @@
"""Обратная связь и права администратора портала (настройки сервера, ответы обратной связи)."""
import os
import tempfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "fb.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())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
def test_feedback_and_portal_admin():
with TestClient(app) as c:
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
root = login("admin", "admin")
me = c.get(f"{V1}/me", headers=root).json()
assert me["is_root"] and me["portal_admin"]
a2 = c.post(f"{V1}/users", headers=root, json={"username": "fb_adm", "full_name": "Второй админ", "password": PW, "is_admin": True}).json()
c.post(f"{V1}/users", headers=root, json={"username": "fb_t", "full_name": "Техник", "password": PW})
adm2, t = login("fb_adm"), login("fb_t")
# обычный администратор: настроек сервера и обратной связи нет, мастер работает (только setup_done)
assert c.get(f"{V1}/me", headers=adm2).json()["portal_admin"] is False
lim = c.get(f"{V1}/settings", headers=adm2).json()
assert lim.get("limited") and "photo_dir" not in lim
assert c.put(f"{V1}/settings", headers=adm2, json={"setup_done": "users"}).status_code == 200
assert c.put(f"{V1}/settings", headers=adm2, json={"max_photo_mb": 5}).status_code == 403
assert c.get(f"{V1}/api-keys", headers=adm2).status_code == 403
assert c.get(f"{V1}/assistant/config", headers=adm2).status_code == 403
assert c.get(f"{V1}/feedback", headers=adm2).status_code == 403
# выдать права может только admin; admin не может лишиться прав
assert c.patch(f"{V1}/users/{a2['id']}", headers=adm2, json={"portal_admin": True}).status_code == 403
assert c.patch(f"{V1}/users/{me['id']}", headers=adm2, json={"is_admin": False}).status_code == 403
assert c.patch(f"{V1}/users/{a2['id']}", headers=root, json={"portal_admin": True}).json()["portal_admin"] is True
assert c.get(f"{V1}/settings", headers=adm2).json().get("photo_dir")
# обратная связь: отправляет любой, видят администраторы портала
r = c.post(f"{V1}/feedback", headers=t, json={"kind": "bug", "text": "Кнопка не видна на телефоне", "page": "#/work", "client": "m"})
assert r.status_code == 201
assert c.get(f"{V1}/feedback", headers=t).status_code == 403
lst = c.get(f"{V1}/feedback", headers=root, params={"status": "open"}).json()
fb = next(x for x in lst if x["text"].startswith("Кнопка"))
assert fb["user_name"] == "Техник" and fb["kind"] == "bug" and fb["page"] == "#/work"
u = c.patch(f"{V1}/feedback/{fb['id']}", headers=adm2, json={"status": "done", "answer": "Исправлено"}).json()
assert u["status"] == "done" and u["answered_by_name"] == "Второй админ"
assert c.post(f"{V1}/feedback", headers=t, json={"text": "x"}).status_code == 422
+91
View File
@@ -0,0 +1,91 @@
"""Анализ надёжности: запись об отказе (форма закрытия), показатели, дрейф параметров, выгрузки."""
import os
import tempfile
from datetime import timedelta
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "rl.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
def test_failure_record_and_reliability():
with TestClient(app) as c:
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
adm = login("admin", "admin")
c.post(f"{V1}/users", headers=adm, json={"username": "rl_t", "full_name": "Дежурный", "password": PW})
t = login("rl_t")
now = config.now()
eq = c.post(f"{V1}/equipment", headers=adm, json={"name": "RL-Насос"}).json()
quiet = c.post(f"{V1}/equipment", headers=adm, json={"name": "RL-Тихий"}).json()
su = c.post(f"{V1}/equipment/{eq['id']}/service-units", headers=adm, json={"name": "RL-Подшипник", "interval_hours": 5000}).json()
for e, pts in ((eq, ((100, 0), (50, 1000), (1, 2000))), (quiet, ((100, 0), (1, 2000)))):
for d, h in pts:
assert c.post(f"{V1}/equipment/{e['id']}/meter", headers=adm,
json={"hours": h, "at": (now - timedelta(days=d)).isoformat()}).status_code == 201
# отказ: снимок наработки при регистрации
f1 = c.post(f"{V1}/incidents", headers=t, json={"equipment_id": eq["id"], "title": "RL-заклинило",
"occurred_at": (now - timedelta(hours=10)).isoformat()}).json()
assert f1["hours_at_open"] == 2000
# проверка хронологии: ввод раньше начала работ
bad = c.post(f"{V1}/incidents/{f1['id']}/close", headers=t, json={
"repair_started_at": (now - timedelta(hours=5)).isoformat(), "restored_at": (now - timedelta(hours=6)).isoformat()})
assert bad.status_code == 422
r = c.post(f"{V1}/incidents/{f1['id']}/close", headers=t, json={
"note": "заменили подшипник", "repair_started_at": (now - timedelta(hours=8)).isoformat(),
"restored_at": (now - timedelta(hours=4)).isoformat(), "parts_wait_hours": 1.5, "cause_unit_id": su["id"],
"action": "replace", "nature": "sudden", "cause": "wear", "precursor": "none", "replaced_units": [su["id"]],
"meter_at_restore": 2001}).json()
assert r["status"] == "closed" and r["hours_at_failure"] == 2000 and r["cause_unit_name"] == "RL-Подшипник"
assert r["units"][0]["replaced"] is True and r["meter_at_restore"] == 2001
units = c.get(f"{V1}/equipment/{eq['id']}/service-units", headers=adm).json()
assert units[0]["last_service_hours"] == 2001 # ресурс узла сброшен при вводе
# второй отказ закрыт старым клиентом (только комментарий) — поля подставляются
f2 = c.post(f"{V1}/incidents", headers=t, json={"equipment_id": eq["id"], "title": "RL-сбой",
"occurred_at": (now - timedelta(days=30)).isoformat()}).json()
r2 = c.post(f"{V1}/incidents/{f2['id']}/close", headers=t, json={"note": "перезапустили"}).json()
assert r2["restored_at"] and r2["repair_started_at"] and r2["cause"] == "unknown"
# инженер уточняет закрытую запись; техник — нет
assert c.patch(f"{V1}/incidents/{f2['id']}/failure", headers=t, json={"action": "restart"}).status_code == 403
e2 = c.patch(f"{V1}/incidents/{f2['id']}/failure", headers=adm, json={
"action": "restart", "restored_at": (now - timedelta(days=30) + timedelta(hours=2)).isoformat()}).json()
assert e2["action"] == "restart"
# неисправность → отказ: признак «было сообщение о неисправности»
fl = c.post(f"{V1}/incidents", headers=t, json={"kind": "fault", "equipment_id": quiet["id"], "title": "RL-шум"}).json()
x = c.post(f"{V1}/incidents/{fl['id']}/escalate", headers=t, json={"note": "встал"}).json()
assert x["precursor"] == "fault_report" and x["escalated_at"] and x["hours_at_failure"] == 2000
# показатели
assert c.get(f"{V1}/reliability", headers=t).status_code == 403
q = {"from": (now - timedelta(days=100)).isoformat(), "to": (now + timedelta(hours=1)).isoformat()}
rep = c.get(f"{V1}/reliability", headers=adm, params=q).json()
rows = {x["equipment"]: x for x in rep["rows"]}
n = rows["RL-Насос"]
assert n["failures"] == 2 and n["basis"] == "meter" and abs(n["hours"] - 2001) < 1
assert abs(n["theta"] - 1000.5) < 1 and n["theta_lower"] < n["theta"] and n["mttr"] is not None and 0 < n["availability"] < 1
assert rows["RL-Тихий"]["failures"] == 1 and rows["RL-Тихий"]["escalated"] == 1
assert {c_["key"] for c_ in rep["causes"]} >= {"wear"}
assert c.get(f"{V1}/me", headers=adm).json()["reliability"] is True
assert c.get(f"{V1}/me", headers=t).json()["reliability"] is False
# выгрузки
csv1 = c.get(f"{V1}/reliability/export", headers=adm, params=q).content.decode("utf-8-sig")
assert "RL-заклинило" in csv1 and "Износ и старение" in csv1 and "Замена узла" in csv1
csv2 = c.get(f"{V1}/reliability/export", headers=adm, params={**q, "kind": "service"}).content.decode("utf-8-sig")
assert "RL-Подшипник" in csv2 and "замена при отказе" in csv2
# выключение раздела
c.put(f"{V1}/settings", headers=adm, json={"reliability": False})
assert c.get(f"{V1}/reliability", headers=adm).status_code == 404
c.put(f"{V1}/settings", headers=adm, json={"reliability": True})
+592
View File
@@ -0,0 +1,592 @@
"""Тесты безопасности: аутентификация, права доступа, изоляция данных, токены, загрузка файлов, инъекции.
Тесты с пометкой 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", "<script>"):
r = c.put(f"{V1}/orders/{o['id']}/items/{num}", headers=h["sec_a"], json={"value": bad})
assert r.status_code in (422,), f"{bad!r} -> {r.status_code}"
# ---------------------------------------------------------------- загрузка файлов
def test_upload_rejects_non_images(env):
c, h, o = env["c"], env["h"], env["order"]
item = o["items"][0]["id"]
url = f"{V1}/orders/{o['id']}/items/{item}/photos"
payloads = {
"html": b"<html><script>alert(1)</script></html>",
"svg": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>',
"exe": b"MZ\x90\x00" + b"\0" * 100,
"php": b"<?php system($_GET['c']); ?>",
"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 = "<img src=x onerror=alert(1)>"
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)
+361
View File
@@ -0,0 +1,361 @@
"""Регламентное обслуживание: узлы машин, наработка, сброс, регламентные наряды, отчёт «Текущее состояние»."""
import os
import tempfile
from datetime import date, datetime, timedelta
import pytest
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "svc.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config, service # noqa: E402
from app.main import app # noqa: E402
from app.models import ServiceUnit # noqa: E402
V1 = "/api/v1"
def bearer(t):
return {"Authorization": "Bearer " + t}
# ---------------------------------------------------------------- расчёт состояния (без БД)
def unit(**kw):
base = dict(mode="hours", interval_hours=500, interval_value=None, interval_unit=None, warn_percent=10,
last_service_at=datetime(2026, 1, 1), last_service_hours=1000, created_at=datetime(2026, 1, 1))
base.update(kw)
return ServiceUnit(**base)
NOW = datetime(2026, 6, 1, 12, 0)
def test_hours_rule_states():
u = unit()
assert service.evaluate(u, 1100, now=NOW)["state"] == "ok"
e = service.evaluate(u, 1100, now=NOW)
assert e["used_hours"] == 100 and e["remaining_hours"] == 400 and e["percent"] == 20
assert service.evaluate(u, 1460, now=NOW)["state"] == "soon" # осталось 40 из 500 (8% ≤ 10%)
assert service.evaluate(u, 1450, now=NOW)["state"] == "soon" # ровно 10%
assert service.evaluate(u, 1449, now=NOW)["state"] == "ok"
assert service.evaluate(u, 1500, now=NOW)["state"] == "due" # ресурс выработан
over = service.evaluate(u, 1620, now=NOW)
assert over["state"] == "due" and over["remaining_hours"] == -120 and over["percent"] == 124
def test_calendar_rule_states_and_months():
u = unit(mode="calendar", interval_hours=None, interval_value=1, interval_unit="month", last_service_at=datetime(2026, 5, 1))
e = service.evaluate(u, 0, now=NOW)
assert e["due_date"] == "2026-06-01" and e["state"] == "due" and e["remaining_days"] == 0
assert service.evaluate(u, 0, now=datetime(2026, 5, 10))["state"] == "ok"
assert service.evaluate(u, 0, now=datetime(2026, 5, 29))["state"] == "soon" # осталось 3 из 31 дня
# конец месяца: 31 января + 1 месяц = 28 февраля
assert service.add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
assert service.add_interval(date(2026, 1, 1), 2, "week") == date(2026, 1, 15)
assert service.rule_text(u) == "раз в месяц"
assert service.rule_text(unit(mode="calendar", interval_value=3, interval_unit="month")) == "раз в 3 месяца"
assert service.rule_text(unit(mode="both", interval_value=6, interval_unit="month")) == "каждые 500 м/ч или раз в 6 месяцев"
def test_both_rule_takes_the_worse_and_forecast():
u = unit(mode="both", interval_value=12, interval_unit="month", last_service_at=datetime(2026, 5, 1))
e = service.evaluate(u, 1480, rate=10, now=NOW) # по часам осталось 20 (4%) — «скоро», по календарю далеко
assert e["state"] == "soon" and e["percent"] == 96
assert e["forecast_date"] == (NOW.date() + timedelta(days=2)).isoformat() # 20 м/ч при 10 м/ч в сутки
assert service.evaluate(u, 1600, now=NOW)["state"] == "due"
assert service.evaluate(unit(mode="hours", interval_hours=None), 5, now=NOW)["state"] == "nodata"
# ---------------------------------------------------------------- API
@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 bearer(r.json()["access_token"])
adm = login("admin", "admin")
always = c.post(f"{V1}/schedules", headers=adm, json={"name": "svc-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": "svc-tech", "kind": "technician", "schedule_id": always["id"]}).json()
eng = c.post(f"{V1}/positions", headers=adm, json={"name": "svc-eng", "kind": "engineer"}).json()
for name, pos in (("svc_t", tech), ("svc_e", eng)):
assert c.post(f"{V1}/users", headers=adm, json={"username": name, "password": "Passw0rd!x", "position_id": pos["id"]}).status_code == 201
h = {"admin": adm, "t": login("svc_t", "Passw0rd!x"), "e": login("svc_e", "Passw0rd!x")}
users = {u["username"]: u["id"] for u in c.get(f"{V1}/users", headers=adm).json()}
loc = c.post(f"{V1}/locations", headers=h["e"], json={"name": "svc-цех"}).json()
yield {"c": c, "h": h, "users": users, "loc": loc, "tech": tech, "eng": eng}
def new_machine(env, name="Компрессор", hours=1000):
c, h = env["c"], env["h"]
eq = c.post(f"{V1}/equipment", headers=h["e"], json={"name": name, "location_id": env["loc"]["id"], "inventory_no": "K-1"}).json()
if hours:
r = c.post(f"{V1}/equipment/{eq['id']}/meter", headers=h["e"], json={"hours": hours})
assert r.status_code == 201, r.text
return eq["id"]
def test_unit_crud_validation_and_rbac(env):
c, h = env["c"], env["h"]
eid = new_machine(env)
url = f"{V1}/equipment/{eid}/service-units"
# правило должно быть задано
assert c.post(url, headers=h["e"], json={"name": "Масло", "mode": "hours"}).status_code == 422
assert c.post(url, headers=h["e"], json={"name": "Масло", "mode": "calendar", "interval_value": 1}).status_code == 422
assert c.post(url, headers=h["e"], json={"name": "Масло", "mode": "hours", "interval_hours": 0}).status_code == 422
# техник не может менять, но может читать
body = {"name": "Масло", "mode": "hours", "interval_hours": 500}
assert c.post(url, headers=h["t"], json=body).status_code == 403
u = c.post(url, headers=h["e"], json=body)
assert u.status_code == 201, u.text
u = u.json()
assert u["state"] == "ok" and u["last_service_hours"] == 1000 and u["remaining_hours"] == 500 # старт: «обслужено сейчас»
assert c.get(url, headers=h["t"]).status_code == 200
assert c.patch(f"{V1}/service-units/{u['id']}", headers=h["t"], json={"name": "x"}).status_code == 403
# правка: добавляем календарное правило
p = c.patch(f"{V1}/service-units/{u['id']}", headers=h["e"], json={"mode": "both", "interval_value": 6, "interval_unit": "month"}).json()
assert p["rule"] == "каждые 500 м/ч или раз в 6 месяцев" and p["due_date"]
assert c.patch(f"{V1}/service-units/{u['id']}", headers=h["e"], json={"mode": "calendar", "interval_unit": None}).status_code == 422
# начальное состояние не может быть «из будущего» по наработке
r = c.post(url, headers=h["e"], json={"name": "Фильтр", "mode": "hours", "interval_hours": 200, "last_service_hours": 5000})
assert r.status_code == 422
# удаление
assert c.delete(f"{V1}/service-units/{u['id']}", headers=h["e"]).status_code == 204
assert c.get(url, headers=h["e"]).json() == []
def test_meter_rules(env):
c, h = env["c"], env["h"]
eid = new_machine(env, "Насос-м", 100)
m = f"{V1}/equipment/{eid}/meter"
assert c.post(m, headers=h["e"], json={"hours": 90}).status_code == 422 # назад нельзя…
assert c.post(m, headers=h["e"], json={"hours": 5, "correction": True, "note": "замена счётчика"}).status_code == 201 # …кроме коррекции
assert c.get(f"{V1}/equipment/{eid}", headers=h["e"]).json()["meter_hours"] == 5
assert c.post(m, headers=h["e"], json={"hours": 10, "at": (config.now() + timedelta(days=1)).isoformat()}).status_code == 422
assert c.post(m, headers=h["t"], json={"hours": 50}).status_code == 403
hist = c.get(m, headers=h["t"]).json()
assert [x["source"] for x in hist][:2] == ["correction", "manual"] and hist[0]["note"] == "замена счётчика"
def test_manual_reset_flow(env):
c, h = env["c"], env["h"]
eid = new_machine(env, "Дизель", 2000)
u = c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json={"name": "Масло", "mode": "hours", "interval_hours": 250}).json()
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 2260})
u = c.get(f"{V1}/equipment/{eid}/service-units", headers=h["e"]).json()[0]
assert u["state"] == "due" and u["remaining_hours"] == -10
# сброс: масло заменили отдельно, на 2255 м/ч
r = c.post(f"{V1}/service-units/{u['id']}/reset", headers=h["e"], json={"hours": 2255, "note": "Замена вне наряда"})
assert r.status_code == 200, r.text
r = r.json()
assert r["state"] == "ok" and r["last_service_hours"] == 2255 and r["remaining_hours"] == 245
# без параметров — «обслужено сейчас, при текущей наработке»
r = c.post(f"{V1}/service-units/{u['id']}/reset", headers=h["e"], json={}).json()
assert r["last_service_hours"] == 2260 and r["remaining_hours"] == 250
# валидация и права
assert c.post(f"{V1}/service-units/{u['id']}/reset", headers=h["e"], json={"hours": 99999}).status_code == 422
assert c.post(f"{V1}/service-units/{u['id']}/reset", headers=h["e"], json={"at": (config.now() + timedelta(days=2)).isoformat()}).status_code == 422
assert c.post(f"{V1}/service-units/{u['id']}/reset", headers=h["t"], json={}).status_code == 403
ev = c.get(f"{V1}/service-units/{u['id']}/events", headers=h["t"]).json()
assert [e["kind"] for e in ev] == ["reset", "reset", "initial"] and ev[1]["note"] == "Замена вне наряда" and ev[0]["user_name"]
def make_regular(env, only_due=False, items=()):
c, h = env["c"], env["h"]
return c.post(f"{V1}/checklists", headers=h["e"], json={"name": f"Регламент {datetime.now().timestamp()}", "kind": "regular",
"only_due": only_due, "items": list(items)}).json()
def test_regular_order_lifecycle(env):
"""Регламентный наряд: пункты узлов подставляются из машины; по завершении узлы сбрасываются, наработка обновляется."""
c, h, users = env["c"], env["h"], env["users"]
eid = new_machine(env, "Погрузчик", 3000)
mk = lambda body: c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json=body).json()
oil = mk({"name": "Масло двигателя", "mode": "hours", "interval_hours": 500})
flt = mk({"name": "Воздушный фильтр", "mode": "hours", "interval_hours": 1000})
belt = mk({"name": "Ремень", "mode": "calendar", "interval_value": 3, "interval_unit": "month"})
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 3480}) # у масла осталось 20 м/ч
cl = make_regular(env, items=[{"text": "Осмотр на течи", "answer_type": "status"}])
assert cl["kind"] == "regular"
# без оборудования регламентный наряд не создать
r = c.post(f"{V1}/orders", headers=h["e"], json={"title": "Рег.", "checklist_id": cl["id"], "assignee_user_id": users["svc_t"]})
assert r.status_code == 422
o = c.post(f"{V1}/orders", headers=h["e"], json={"title": "ТО погрузчика", "checklist_id": cl["id"], "equipment_id": eid,
"assignee_user_id": users["svc_t"]})
assert o.status_code == 201, o.text
o = o.json()
items = o["items"]
assert items[0]["is_meter"] and items[0]["answer_type"] == "number" and items[0]["unit"] == "м/ч" and items[0]["min_value"] == 3480
assert items[1]["text"] == "Осмотр на течи" and not items[1]["service_unit_id"]
by_unit = {i["service_unit_id"]: i for i in items if i["service_unit_id"]}
assert set(by_unit) == {oil["id"], flt["id"], belt["id"]}
assert "осталось 20 м/ч" in by_unit[oil["id"]]["text"] and by_unit[oil["id"]]["answer_type"] == "status"
# техник: наработка + «обслужено» для масла и осмотра; фильтр и ремень не трогаем
t = h["t"]
put = lambda iid, v: c.put(f"{V1}/orders/{o['id']}/items/{iid}", headers=t, json={"value": v})
assert put(items[0]["id"], "3490").status_code == 200
assert put(items[1]["id"], "done").status_code == 200
assert put(by_unit[oil["id"]]["id"], "done").status_code == 200
# до завершения ничего не сброшено
assert c.get(f"{V1}/equipment/{eid}", headers=h["e"]).json()["meter_hours"] == 3480
assert c.post(f"{V1}/orders/{o['id']}/submit", headers=t).json()["status"] == "completed" # проверяющего нет
assert c.get(f"{V1}/equipment/{eid}", headers=h["e"]).json()["meter_hours"] == 3490
st = {u["name"]: u for u in c.get(f"{V1}/equipment/{eid}/service-units", headers=h["e"]).json()}
assert st["Масло двигателя"]["last_service_hours"] == 3490 and st["Масло двигателя"]["remaining_hours"] == 500
assert st["Воздушный фильтр"]["last_service_hours"] == 3000 # не отмечен — не сброшен
ev = c.get(f"{V1}/service-units/{oil['id']}/events", headers=h["e"]).json()
assert ev[0]["kind"] == "order" and ev[0]["order_id"] == o["id"]
# повторная обработка того же наряда ничего не дублирует
from app.db import SessionLocal
from app.models import Order, ServiceEvent
with SessionLocal() as s:
service.apply_completed_order(s, s.get(Order, o["id"]))
s.commit()
assert s.query(ServiceEvent).filter(ServiceEvent.unit_id == oil["id"], ServiceEvent.order_id == o["id"]).count() == 1
def test_regular_order_with_review_applies_only_on_approval(env):
c, h, users = env["c"], env["h"], env["users"]
eid = new_machine(env, "Генератор", 100)
u = c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json={"name": "Свечи", "mode": "hours", "interval_hours": 100}).json()
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 195})
cl = make_regular(env)
o = c.post(f"{V1}/orders", headers=h["e"], json={"title": "ТО ген.", "checklist_id": cl["id"], "equipment_id": eid,
"assignee_user_id": users["svc_t"], "reviewer_user_id": users["svc_e"]}).json()
unit_item = next(i for i in o["items"] if i["service_unit_id"])
c.put(f"{V1}/orders/{o['id']}/items/{o['items'][0]['id']}", headers=h["t"], json={"value": "200"})
c.put(f"{V1}/orders/{o['id']}/items/{unit_item['id']}", headers=h["t"], json={"value": "done"})
assert c.post(f"{V1}/orders/{o['id']}/submit", headers=h["t"]).json()["status"] == "review"
assert c.get(f"{V1}/equipment/{eid}/service-units", headers=h["e"]).json()[0]["last_service_hours"] == 100 # ещё не сброшено
assert c.post(f"{V1}/orders/{o['id']}/review", headers=h["e"], json={"approve": True, "comment": ""}).json()["status"] == "completed"
r = c.get(f"{V1}/equipment/{eid}/service-units", headers=h["e"]).json()[0]
assert r["last_service_hours"] == 200 and r["state"] == "ok"
# возврат на доработку ничего не сбрасывает
eid2 = new_machine(env, "Генератор-2", 10)
c.post(f"{V1}/equipment/{eid2}/service-units", headers=h["e"], json={"name": "Свечи", "mode": "hours", "interval_hours": 100})
o2 = c.post(f"{V1}/orders", headers=h["e"], json={"title": "ТО-2", "checklist_id": cl["id"], "equipment_id": eid2,
"assignee_user_id": users["svc_t"], "reviewer_user_id": users["svc_e"]}).json()
for it in o2["items"]:
c.put(f"{V1}/orders/{o2['id']}/items/{it['id']}", headers=h["t"], json={"value": "50" if it["is_meter"] else "done"})
c.post(f"{V1}/orders/{o2['id']}/submit", headers=h["t"])
c.post(f"{V1}/orders/{o2['id']}/review", headers=h["e"], json={"approve": False, "comment": "переделать"})
assert c.get(f"{V1}/equipment/{eid2}/service-units", headers=h["e"]).json()[0]["last_service_hours"] == 10
def test_only_due_filter_and_empty_cases(env):
c, h, users = env["c"], env["h"], env["users"]
eid = new_machine(env, "Пресс", 500)
c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json={"name": "Гидравлика", "mode": "hours", "interval_hours": 1000})
cl = make_regular(env, only_due=True)
body = {"title": "ТО пресса", "checklist_id": cl["id"], "equipment_id": eid, "assignee_user_id": users["svc_t"]}
r = c.post(f"{V1}/orders", headers=h["e"], json=body)
assert r.status_code == 422 # узлы в норме и своих пунктов нет — наряд не нужен
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 1450})
assert c.post(f"{V1}/orders", headers=h["e"], json=body).status_code == 201 # 50 из 1000 (5%) — «скоро»
# шаблон автозапуска на регламентный чек-лист требует машину
t = c.post(f"{V1}/order-templates", headers=h["e"], json={"title": "Авто", "checklist_id": cl["id"], "recurrence": "daily",
"assignee_user_id": users["svc_t"]})
assert t.status_code == 422
t = c.post(f"{V1}/order-templates", headers=h["e"], json={"title": "Авто", "checklist_id": cl["id"], "equipment_id": eid,
"recurrence": "daily", "assignee_user_id": users["svc_t"]})
assert t.status_code == 201
def test_scheduler_skips_templates_that_cannot_create_orders(env):
"""Шаблон регламентного ТО у машины без узлов не должен ломать планировщик остальных шаблонов."""
from app import logic
from app.db import SessionLocal
from app.models import OrderTemplate
c, h, users = env["c"], env["h"], env["users"]
eid = new_machine(env, "Пустая", 0)
cl = make_regular(env)
ok_cl = c.post(f"{V1}/checklists", headers=h["e"], json={"name": "обычный", "items": [{"text": "a", "answer_type": "status"}]}).json()
with SessionLocal() as s:
past = config.now() - timedelta(hours=1)
bad = OrderTemplate(title="плохой", checklist_id=cl["id"], equipment_id=eid, assignee_user_id=users["svc_t"], recurrence="daily", is_active=True, next_run=past,
start_date=past.date())
good = OrderTemplate(title="хороший", checklist_id=ok_cl["id"], assignee_user_id=users["svc_t"], recurrence="daily", is_active=True, next_run=past,
start_date=past.date())
s.add_all([bad, good])
s.commit()
created = logic.run_due_templates(s)
assert [o.title for o in created] == ["хороший"]
s.refresh(bad)
assert bad.next_run > config.now()
def test_state_report(env):
c, h = env["c"], env["h"]
eid = new_machine(env, "Отчётная машина", 100)
mk = lambda body: c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json=body).json()
mk({"name": "=Масло", "mode": "hours", "interval_hours": 50}) # просрочится
mk({"name": "Фильтр", "mode": "hours", "interval_hours": 1000}) # в норме
mk({"name": "Ремень", "mode": "hours", "interval_hours": 100}) # скоро
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 195})
base = f"{V1}/reports/equipment-state"
j = c.get(base, headers=h["e"], params={"equipment_id": eid}).json()
assert [r["unit"] for r in j["rows"]] == ["=Масло", "Ремень", "Фильтр"] # сначала «Требуется», потом «Скоро»
assert [r["state"] for r in j["rows"]] == ["due", "soon", "ok"] and j["summary"] == {"due": 1, "soon": 1, "ok": 1, "nodata": 0}
assert j["rows"][0]["remaining_hours"] == -45
att = c.get(base, headers=h["e"], params={"equipment_id": eid, "state": "attention"}).json()
assert [r["state"] for r in att["rows"]] == ["due", "soon"]
assert [r["unit"] for r in c.get(base, headers=h["e"], params={"equipment_id": eid, "state": "ok"}).json()["rows"]] == ["Фильтр"]
assert c.get(base, headers=h["e"], params={"state": "bad"}).status_code == 422
assert c.get(base, headers=h["t"]).status_code == 403
pdf = c.get(base, headers=h["e"], params={"equipment_id": eid, "format": "pdf"})
assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF"
csv_ = c.get(base, headers=h["e"], params={"equipment_id": eid, "format": "csv"})
text = csv_.content.decode("utf-8-sig")
assert csv_.status_code == 200 and "Состояние" in text and "'=Масло" in text and ";=Масло" not in text # формулы экранированы
# неактивный узел скрыт, пока не попросят
u_id = j["rows"][1]["unit_id"]
c.patch(f"{V1}/service-units/{u_id}", headers=h["e"], json={"is_active": False})
assert len(c.get(base, headers=h["e"], params={"equipment_id": eid}).json()["rows"]) == 2
assert len(c.get(base, headers=h["e"], params={"equipment_id": eid, "include_inactive": True}).json()["rows"]) == 3
def test_forecast_uses_meter_history(env):
from app.db import SessionLocal
from app.models import MeterReading, Equipment
c, h = env["c"], env["h"]
eid = new_machine(env, "Прогноз", 0)
c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json={"name": "Масло", "mode": "hours", "interval_hours": 400})
with SessionLocal() as s: # 10 суток назад — 0 м/ч, сейчас — 100 м/ч → 10 м/ч в сутки
eq = s.get(Equipment, eid)
s.add(MeterReading(equipment_id=eid, hours=0, at=config.now() - timedelta(days=10), source="manual"))
s.add(MeterReading(equipment_id=eid, hours=100, at=config.now(), source="manual"))
eq.meter_hours, eq.meter_updated_at = 100, config.now()
s.commit()
row = c.get(f"{V1}/reports/equipment-state", headers=h["e"], params={"equipment_id": eid}).json()["rows"][0]
assert row["daily_rate"] == 10.0
assert row["forecast_date"] == (config.now().date() + timedelta(days=30)).isoformat() # осталось 300 м/ч / 10 в сутки
def test_checklist_kind_roundtrip(env):
c, h = env["c"], env["h"]
cl = c.post(f"{V1}/checklists", headers=h["e"], json={"name": "рег-1", "kind": "regular", "only_due": True}).json()
got = c.get(f"{V1}/checklists/{cl['id']}", headers=h["t"]).json()
assert got["kind"] == "regular" and got["only_due"] is True
upd = c.put(f"{V1}/checklists/{cl['id']}", headers=h["e"], json={"name": "рег-1", "kind": "inspection", "items": [{"text": "x"}]}).json()
assert upd["kind"] == "inspection" and upd["only_due"] is False
assert c.post(f"{V1}/checklists", headers=h["e"], json={"name": "x", "kind": "weird"}).status_code == 422
def test_report_summary_ignores_state_filter(env):
c, h = env["c"], env["h"]
eid = new_machine(env, "Сводка", 100)
for n, hrs_ in (("a", 50), ("b", 1000)):
c.post(f"{V1}/equipment/{eid}/service-units", headers=h["e"], json={"name": n, "mode": "hours", "interval_hours": hrs_})
c.post(f"{V1}/equipment/{eid}/meter", headers=h["e"], json={"hours": 160})
j = c.get(f"{V1}/reports/equipment-state", headers=h["e"], params={"equipment_id": eid, "state": "due"}).json()
assert [r["unit"] for r in j["rows"]] == ["a"] and j["total"] == 1
assert j["summary"]["due"] == 1 and j["summary"]["ok"] == 1 # сводка — по всем узлам машины
+83
View File
@@ -0,0 +1,83 @@
"""AI-помощник: настройки (ключ не утекает), фильтр тематики, инструменты с правами пользователя, лимиты."""
import os
import tempfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "as.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())
from fastapi.testclient import TestClient # noqa: E402
from app import assistant as A # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
def test_assistant(monkeypatch):
with TestClient(app) as c:
adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
c.post(f"{V1}/users", headers=adm, json={"username": "as_tech", "password": "Pass1234567", "full_name": "Помощников Тест"})
tech = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "as_tech", "password": "Pass1234567"}).json()["access_token"]}
ask = lambda q, h=tech: c.post(f"{V1}/assistant/chat", headers=h, json={"messages": [{"role": "user", "content": q}]})
# выключен по умолчанию
assert c.get(f"{V1}/assistant/status", headers=tech).json() == {"enabled": False}
assert ask("где задания?").status_code == 503
# настройки — только админ; без ключа включить нельзя
assert c.put(f"{V1}/assistant/config", headers=tech, json={"enabled": True}).status_code == 403
assert c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True, "model": "m"}).status_code == 422
assert c.put(f"{V1}/assistant/config", headers=adm, json={"base_url": "ftp://x"}).status_code == 422
r = c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True, "provider": "openai", "model": "test-model",
"api_key": "sk-secret-value-1234", "base_url": "https://llm.example/v1"})
assert r.status_code == 200 and r.json()["api_key_set"] and "sk-secret" not in r.text and r.json()["api_key_hint"] == "…1234"
assert "sk-secret" not in c.get(f"{V1}/settings", headers=adm).text
assert c.get(f"{V1}/assistant/status", headers=tech).json() == {"enabled": True}
calls = []
async def fake(cfg, system, messages):
calls.append((system, messages))
q = messages[-1]["content"]
if "стих" in q:
return "OFFTOPIC"
if "задани" in q and not q.startswith("РЕЗУЛЬТАТ"):
return 'CALL my_tasks {}'
if "склад" in q and not q.startswith("РЕЗУЛЬТАТ"):
return 'CALL warehouses {}'
if q.startswith("РЕЗУЛЬТАТ"):
return "Ответ по данным: " + q[:200]
return "Откройте [Суточные задания](#/tasks)."
monkeypatch.setattr(A, "complete", fake)
# системный промт знает пользователя и его роль; справка в нём
r = ask("Как поставить задание?")
assert r.status_code == 200, r.text
sys = calls[-1][0]
assert "Помощников Тест" in sys and "техник" in sys and "Задачи и поручения" in sys and "OFFTOPIC" in sys
# не по теме — вежливый отказ, а не ответ модели
r = ask("Напиши стих про весну").json()
assert r["offtopic"] and r["reply"] == A.REFUSAL
# инструмент вызывается от имени пользователя: его задания
c.post(f"{V1}/tasks", headers=tech, json={"title": "Проверить насос помощником"})
r = ask("Какие у меня задания?").json()
assert r["tools"] == ["my_tasks"] and "Проверить насос помощником" in r["reply"]
# нет прав — помощник получает отказ, а не данные
out = __import__("asyncio").run(A.run_tool(app, tech["Authorization"][7:], "equipment_units", {"id": "abc"}))
assert "числовой" in out
# длина вопроса и частота
assert ask("x" * 1500).status_code == 422
A._hits.clear()
monkeypatch.setattr(A, "RATE", (2, 3600))
assert ask("где наряды?").status_code == 200 and ask("где наряды?").status_code == 200
assert ask("где наряды?").status_code == 429
A._hits.clear()
# ключ не попадает в резервную копию
b = c.post(f"{V1}/backups", headers=adm, json={}).json()
import io, json, zipfile
z = zipfile.ZipFile(io.BytesIO(c.get(f"{V1}/backups/{b['name']}", headers=adm).content))
assert "sk-secret" not in z.read("db/settings.json").decode()
# выключение
c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": False})
assert c.get(f"{V1}/assistant/status", headers=tech).json() == {"enabled": False}
+72
View File
@@ -0,0 +1,72 @@
"""Резервное копирование: создание, состав, восстановление (в т. ч. циклические ссылки), права, защита имён."""
import io
import json
import os
import tempfile
import zipfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "bk.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())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 64
def test_backup_and_restore():
with TestClient(app) as c:
H = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
c.post(f"{V1}/users", headers=H, json={"username": "bk_tech", "password": "Pass1234567"})
T = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "bk_tech", "password": "Pass1234567"}).json()["access_token"]}
assert c.get(f"{V1}/backups", headers=T).status_code == 403
# данные с циклом «отдел ↔ руководитель» и вложенностью
boss = c.post(f"{V1}/users", headers=H, json={"username": "bk_boss", "password": "Pass1234567"}).json()
top = c.post(f"{V1}/departments", headers=H, json={"name": "БК-верх", "head_user_id": boss["id"]}).json()
sub = c.post(f"{V1}/departments", headers=H, json={"name": "БК-низ", "parent_id": top["id"]}).json()
c.patch(f"{V1}/users/{boss['id']}", headers=H, json={"department_id": sub["id"]})
eq = c.post(f"{V1}/equipment", headers=H, json={"name": "БК-насос"}).json()
c.put(f"{V1}/map/image", headers=H, content=PNG)
c.put(f"{V1}/settings", headers=H, json={"backup_keep": 5, "backup_auto_hours": 12})
r = c.post(f"{V1}/backups", headers=H, json={"include_media": True, "note": "тест"})
assert r.status_code == 201, r.text
name = r.json()["name"]
assert r.json()["media"] and r.json()["rows"] > 0
z = zipfile.ZipFile(io.BytesIO(c.get(f"{V1}/backups/{name}", headers=H).content))
names = z.namelist()
assert "manifest.json" in names and "db/users.json" in names and "config/environment.json" in names
assert any(n.startswith("media/") and "/map/" in n for n in names)
env = json.loads(z.read("config/environment.json"))
assert "SECRET_KEY" not in env["variables"] and "SECRET_KEY" in env["secrets_not_saved"]
assert env["server_settings"]["backup_keep"] == "5"
assert "db/refresh_tokens.json" not in names
lst = c.get(f"{V1}/backups", headers=H).json()
assert lst["auto_hours"] == 12 and any(x["name"] == name for x in lst["items"])
# меняем данные и восстанавливаем
c.delete(f"{V1}/equipment/{eq['id']}", headers=H)
c.post(f"{V1}/equipment", headers=H, json={"name": "БК-лишнее"})
assert c.post(f"{V1}/backups/{name}/restore", headers=H, json={}).status_code == 422
res = c.post(f"{V1}/backups/{name}/restore", headers=H, json={"confirm": True})
assert res.status_code == 200, res.text
assert res.json()["safety_copy"].endswith("before-restore.zip")
H = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
names_eq = [e["name"] for e in c.get(f"{V1}/equipment", headers=H).json()]
assert "БК-насос" in names_eq and "БК-лишнее" not in names_eq
deps = {d["name"]: d for d in c.get(f"{V1}/departments", headers=H).json()}
assert deps["БК-низ"]["parent_id"] == deps["БК-верх"]["id"] and deps["БК-верх"]["head_user_id"] == boss["id"]
assert c.get(f"{V1}/map/image", headers=H).content == PNG
new = c.post(f"{V1}/equipment", headers=H, json={"name": "БК-после"}) # счётчики id не сломаны
assert new.status_code == 201
# загрузка чужого файла и защита имён
assert c.post(f"{V1}/backups/upload", headers=H, content=b"not a zip").status_code == 422
up = c.post(f"{V1}/backups/upload", headers=H, content=c.get(f"{V1}/backups/{name}", headers=H).content)
assert up.status_code == 201 and up.json()["name"].endswith("_upload.zip")
assert c.get(f"{V1}/backups/..%2F..%2Fapp.db", headers=H).status_code == 404
assert c.delete(f"{V1}/backups/{up.json()['name']}", headers=H).status_code == 204