Files
ayurishchevandClaude Opus 5.5 e1f197ccff Тесты по модулям предметных областей
Пункт 12 ревью 2026-09-28 17:35 (docs/changes/026): tests/test_app.py
(936 строк) разнесён на test_security, test_devices, test_operations,
test_backups, test_events, test_ids_migrations, test_architecture; общие
хелперы — tests/helpers.py. Тела тестов не менялись (AST совпадает).
security.reset_all_failures() — фикстура сбрасывает счётчики попыток по
всем ключам, тесты не зависят от порядка.

Тесты: 36 из 36 полным прогоном, каждый модуль отдельно и в обратном
порядке; тест-линтер после переноса проверен мутацией.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:30:23 +03:00

176 lines
8.9 KiB
Python

import json
from datetime import date, datetime, timezone
import httpx
import pytest
from fastapi.testclient import TestClient
from app import ids, s3
from app.db import session_scope
from app.main import create_app
from app.models import Backup
from app.services import backups, devices, events, groups, ops
from tests.conftest import ADMIN_PASSWORD, API_TOKEN
from tests.helpers import ros_client
@pytest.mark.asyncio
async def test_backup_flow(monkeypatch):
calls, export_body = [], {}
def handler(r: httpx.Request) -> httpx.Response:
calls.append(f"{r.method} {r.url.path}")
if r.url.path == "/rest/export":
export_body.update(json.loads(r.content))
if r.url.path == "/rest/file" and r.method == "GET":
return httpx.Response(200, json=[{".id": "*1", "size": "5"}])
if r.url.path == "/rest/execute":
return httpx.Response(200, json={"ret": "aGVsbG8="}) # base64("hello")
return httpx.Response(200, json=[])
uploaded = {}
metas = {}
async def fake_upload(path, key, metadata=None): # содержимое читаем до удаления временной папки
uploaded[key] = open(path, "rb").read()
metas[key] = metadata
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
monkeypatch.setattr(s3, "upload_file", fake_upload)
await ops.run_backup(d.id)
posts = [c for c in calls if c.startswith("POST")]
assert posts == ["POST /rest/system/backup/save", "POST /rest/export",
"POST /rest/execute", "POST /rest/execute"] # по одному блоку на файл
assert "show-sensitive" in export_body
with session_scope() as s: # файлы в бакете связаны с метаданными: ID копии и устройства в S3-metadata
b = s.query(Backup).one()
assert ids.is_id(b.id, "bkp") and b.device_id == d.id and b.device_name == "r1"
assert all(m == {"backup-id": b.id, "device-id": d.id} for m in metas.values()) and len(metas) == 2 # .rsc с секретами: из него можно восстановить всё
assert calls.count("DELETE /rest/file/*1") == 2 # файлы удалены с устройства
assert sorted(uploaded.values()) == [b"hello", b"hello"]
assert all(k.startswith("backups/r1/") for k in uploaded)
with session_scope() as s:
assert s.query(Backup).one().status == "done"
assert devices.get_device(d.id).last_backup_requested_at is not None
@pytest.mark.asyncio
async def test_backup_filters(monkeypatch):
office = groups.create_group("Офис")
devices.create_device("a", "10.0.0.1", 80, "u", "p", group_id=office.id)
devices.create_device("c", "10.0.0.3", 80, "u", "p")
def item(key, day):
return {"key": key, "size": 1, "last_modified": datetime(2026, 9, day, 12, tzinfo=timezone.utc)}
items = [item("backups/a/1.backup", 10), item("backups/a/1.rsc", 10),
item("backups/c/2.rsc", 20), item("backups/gone/3.rsc", 20)] # gone — устройство удалено
async def fake_list(device=None):
return [i for i in items if device is None or i["key"].startswith(f"backups/{device}/")]
monkeypatch.setattr(s3, "list_backups", fake_list)
keys = lambda r: [i["key"] for i in r]
assert keys(await backups.list_backups(group=str(office.id))) == ["backups/a/1.backup", "backups/a/1.rsc"]
assert keys(await backups.list_backups(group="none", kind="rsc")) == ["backups/c/2.rsc", "backups/gone/3.rsc"]
assert keys(await backups.list_backups(date_from=date(2026, 9, 15))) == ["backups/c/2.rsc", "backups/gone/3.rsc"]
def test_bulk_delete_backups(monkeypatch):
"""Групповое удаление: чужой ключ отклоняет всю операцию; допустимые — удаляются (UI и API)."""
removed = []
async def fake_delete(key):
removed.append(key)
async def empty_list(device=None): # после удаления сервис сверяет метаданные с бакетом
return []
monkeypatch.setattr(s3, "delete_object", fake_delete)
monkeypatch.setattr(s3, "list_backups", empty_list)
ok = ["backups/a/1.backup", "backups/a/1.rsc"]
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
# UI: ключ вне префикса бэкапов -> ошибка, ничего не удалено
r = c.post("/backups/delete-many", data={"key": ok + ["other/secret.txt"]}, follow_redirects=False)
assert r.status_code == 400 and removed == []
# UI: допустимые ключи -> возврат на страницу с итогом
r = c.post("/backups/delete-many", data={"key": ok, "next": "/backups?group=1"}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/backups?group=1&deleted=2&failed=0"
assert sorted(removed) == ok
# API
removed.clear()
h = {"Authorization": f"Bearer {API_TOKEN}"}
assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ok}).json() == {"deleted": 2, "failed": 0}
assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ["x/../y"]}).status_code == 400
# UI: одиночное удаление — тот же сервис delete_many (метаданные и событие backup.deleted не теряются)
removed.clear()
r = c.post("/backups/delete", data={"key": ok[0]}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/backups?deleted=1&failed=0"
assert removed == [ok[0]]
@pytest.mark.asyncio
async def test_backup_files_get_ids_and_deletion_is_recorded(monkeypatch):
"""Файлы бакета без метаданных получают bkp_ID (парой .backup/.rsc); пропавшие из бакета — помечаются удалёнными."""
items = [{"key": f"backups/r1/{s}", "size": 1, "last_modified": datetime(2026, 9, 10, 12, tzinfo=timezone.utc)}
for s in ("20260910-1.backup", "20260910-1.rsc")]
listing = {"items": items}
async def fake_list(device=None):
return listing["items"]
monkeypatch.setattr(s3, "list_backups", fake_list)
found, _ = await backups.search()
assert len({i["backup_id"] for i in found}) == 1 and ids.is_id(found[0]["backup_id"], "bkp") # одна копия = пара файлов
again, _ = await backups.search()
assert again[0]["backup_id"] == found[0]["backup_id"] # повторный вызов (из кэша) ID не меняет и дубликатов не создаёт
listing["items"] = []
await backups.search(refresh=True) # изменения бакета мимо кэша — принудительное обновление
with session_scope() as s:
b = s.query(Backup).one()
assert b.deleted_at is not None and b.id == found[0]["backup_id"]
assert [e.type for e in events.list_events(entity_id=b.id)] == ["backup.deleted", "backup.imported"]
@pytest.mark.asyncio
async def test_bucket_list_is_cached_between_reads(monkeypatch):
"""Подряд идущие чтения бакета не обращаются к S3 повторно; refresh=True и invalidate() — обращаются."""
calls = []
async def fake_list(device=None):
calls.append(1)
return []
monkeypatch.setattr(s3, "list_backups", fake_list)
await backups.search()
await backups.search()
assert len(calls) == 1 # второй вызов — из кэша
await backups.search(refresh=True)
assert len(calls) == 2 # принудительное обновление, минуя кэш
backups.invalidate()
await backups.search()
assert len(calls) == 3 # invalidate() сбрасывает кэш
# invalidate() пришедший, пока чтение бакета уже шло (гонка: run_backup/delete_many завершились
# во время открытой страницы «Бэкапы») не должен теряться — следующий search() обязан перечитать бакет
async def fake_list_race(device=None):
calls.append(1)
if len(calls) == 4: # ровно один раз, при первом чтении в этом сценарии
backups.invalidate()
return []
monkeypatch.setattr(s3, "list_backups", fake_list_race)
backups.invalidate() # кэш после предыдущего шага свежий — начать сценарий с чтения бакета
await backups.search()
assert len(calls) == 4
await backups.search()
assert len(calls) == 5 # кэш не помечен свежим из-за invalidate() во время предыдущего чтения