Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
571 lines
34 KiB
Python
571 lines
34 KiB
Python
import asyncio
|
|
import html
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import db, ids, s3, security
|
|
from app.main import create_app
|
|
from app.models import Backup, Device, Event, now
|
|
from app.db import session_scope
|
|
from app.ros import operations as ros
|
|
from app.ros.client import RosClient
|
|
from app.services import backups, devices, events, groups, jobs, ops, settings
|
|
|
|
|
|
def ros_client(handler) -> RosClient:
|
|
return RosClient("r1", 443, "admin", "pw", transport=httpx.MockTransport(handler))
|
|
|
|
|
|
def test_password_encryption_roundtrip():
|
|
token = security.encrypt("s3cret")
|
|
assert token != "s3cret" and security.decrypt(token) == "s3cret"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_parsing():
|
|
data = {
|
|
"/rest/system/resource": {"board-name": "hAP ax3", "version": "7.15 (stable)", "uptime": "1d2h"},
|
|
"/rest/system/routerboard": {"routerboard": "true", "model": "C53UiG", "current-firmware": "7.14", "upgrade-firmware": "7.15"},
|
|
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.15", "latest-version": "7.16", "status": "New version is available"},
|
|
"/rest/system/package/update/check-for-updates": [], # POST: проверка обновлений
|
|
}
|
|
async with ros_client(lambda r: httpx.Response(200, json=data[r.url.path])) as c:
|
|
st = await ros.get_status(c)
|
|
assert (st["model"], st["channel"], st["fw_upgrade"], st["ros_latest"]) == ("C53UiG", "stable", "7.15", "7.16")
|
|
assert st["ros_installed"] == "7.15" and st["ros_check_error"] is None
|
|
|
|
|
|
@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
|
|
|
|
|
|
def test_api_auth_and_no_password_leak():
|
|
with TestClient(create_app()) as client:
|
|
assert client.get("/api/v1/devices").status_code == 401
|
|
h = {"Authorization": "Bearer test-token"}
|
|
r = client.post("/api/v1/devices", headers=h, json={
|
|
"name": "r1", "host": "10.0.0.1", "username": "admin", "password": "pw"})
|
|
assert r.status_code == 201 and "password" not in r.text
|
|
assert client.get("/api/v1/devices", headers=h).json()[0]["name"] == "r1"
|
|
|
|
|
|
def test_groups_and_device_filters():
|
|
office, store = groups.create_group("Офис"), groups.create_group("Склад")
|
|
a = devices.create_device("a", "10.0.0.1", 80, "u", "p", group_id=office.id)
|
|
b = devices.create_device("b", "10.0.0.2", 80, "u", "p", group_id=store.id)
|
|
devices.create_device("c", "10.0.0.3", 80, "u", "p")
|
|
with session_scope() as s:
|
|
s.get(Device, a.id).online = True
|
|
s.get(Device, a.id).status_json = json.dumps({"ros_installed": "7.1", "ros_latest": "7.2", "channel": "stable"})
|
|
s.get(Device, b.id).online = False
|
|
|
|
def names(**kw):
|
|
return sorted(d.name for d in devices.filter_devices(devices.list_devices(), **kw))
|
|
|
|
assert names(group=str(office.id)) == ["a"] and names(group="none") == ["c"]
|
|
assert names(status="offline") == ["b", "c"] and names(updates="ros") == ["a"]
|
|
assert names(q="10.0.0.2") == ["b"] and names(channel="stable") == ["a"]
|
|
|
|
groups.delete_group(office.id) # устройства остаются, но без группы
|
|
assert devices.get_device(a.id).group_id is 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"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_firmware_reboots_on_log_message():
|
|
"""Перезагрузка — по записи в журнале (current-firmware до неё не меняется)."""
|
|
def run(log_at_poll, old_entry_time):
|
|
calls, polls = [], {"n": 0}
|
|
|
|
def handler(r: httpx.Request) -> httpx.Response:
|
|
calls.append(f"{r.method} {r.url.path}")
|
|
data = {
|
|
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.22", "upgrade-firmware": "7.24"},
|
|
"/rest/system/clock": {"date": "2026-09-19", "time": "12:00:00"},
|
|
"/rest/system/resource": {"uptime": "1h"}, # загрузка была в 11:00
|
|
}
|
|
if r.url.path == "/rest/log":
|
|
polls["n"] += 1
|
|
log = [{".id": "*1", "time": old_entry_time, "message": ros.FW_DONE_MSG}]
|
|
if polls["n"] >= log_at_poll:
|
|
log.append({".id": "*9", "time": "2026-09-19 12:00:00", "message": ros.FW_DONE_MSG})
|
|
return httpx.Response(200, json=log)
|
|
return httpx.Response(200, json=data.get(r.url.path, []))
|
|
|
|
async def go():
|
|
async with ros_client(handler) as c:
|
|
return await ros.upgrade_firmware(c, wait_seconds=10, poll=0)
|
|
return calls, go()
|
|
|
|
# 1) новая запись появилась на 3-м чтении журнала -> команда upgrade, затем сразу reboot
|
|
calls, coro = run(3, "2026-09-19 08:00:00")
|
|
await coro
|
|
assert calls.index("POST /rest/system/routerboard/upgrade") < calls.index("POST /rest/system/reboot") == len(calls) - 1
|
|
|
|
# 2) запись новее последней загрузки уже есть (прошивка записана, перезагрузки не было) -> сразу reboot
|
|
calls, coro = run(99, "2026-09-19 11:30:00")
|
|
await coro
|
|
assert "POST /rest/system/routerboard/upgrade" not in calls and calls[-1] == "POST /rest/system/reboot"
|
|
|
|
|
|
def test_create_device_via_ui_binds_group(monkeypatch):
|
|
"""Добавление устройства из окна: привязка к существующей группе и создание группы «на месте»."""
|
|
async def no_poll(_device_id): # без обращения к реальному устройству
|
|
return None
|
|
|
|
monkeypatch.setattr(ops, "refresh_status", no_poll)
|
|
office = groups.create_group("Офис")
|
|
form = dict(host="10.0.0.1", port="80", username="u", password="p")
|
|
htmx = {"HX-Request": "true"}
|
|
with TestClient(create_app()) as c:
|
|
c.post("/login", data={"username": "admin", "password": "pw"})
|
|
# 1) существующая группа
|
|
r = c.post("/devices/new", data={**form, "name": "a", "group_id": str(office.id), "note": " Серверная, 2 этаж "},
|
|
follow_redirects=False)
|
|
assert r.status_code == 303
|
|
# имя устройства в таблице открывает окно изменения (без JS — запасную страницу)
|
|
page = c.get("/").text
|
|
assert re.search(r'class="dev-link" href="/devices/(dev_[0-9a-f-]+)/edit" hx-get="/ui/dialog/device/\1"', page)
|
|
# 2) «Новая группа…» из окна: группа создаётся и привязывается, окно просит обновить страницу
|
|
r = c.post("/devices/new", data={**form, "name": "b", "group_id": "__new__", "new_group": "Склад"}, headers=htmx)
|
|
assert r.headers["HX-Refresh"] == "true"
|
|
# 3) «Новая группа…» без названия: ошибка в окне, устройство не создаётся
|
|
r = c.post("/devices/new", data={**form, "name": "c", "group_id": "__new__", "new_group": ""}, headers=htmx)
|
|
assert "Введите название новой группы" in r.text and 'id="modal-title"' in r.text
|
|
|
|
# окна групп открываются и показывают ошибку (дубликат) внутри окна
|
|
assert "Новая группа" in c.get("/ui/dialog/group").text
|
|
r = c.post("/groups/new", data={"name": "Склад"}, headers=htmx)
|
|
assert "уже существует" in r.text and 'id="modal-title"' in r.text
|
|
|
|
by_name = {d.name: d for d in devices.list_devices()}
|
|
store = next(g for g in groups.list_groups() if g["name"] == "Склад")
|
|
assert by_name["a"].group_id == office.id and by_name["b"].group_id == store["id"] and "c" not in by_name
|
|
assert by_name["a"].note == "Серверная, 2 этаж" and by_name["b"].note is None # пробелы обрезаются, пусто -> None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_marks_offline_and_recovers(monkeypatch):
|
|
"""Фоновый опрос: недоступность видна сразу; после возвращения — полный опрос (версии обновились)."""
|
|
d = devices.create_device("r1", "10.0.0.1", 80, "u", "p")
|
|
up = {"on": False}
|
|
data = {
|
|
"/rest/system/resource": {"version": "7.24.4 (stable)", "uptime": "1m", "board-name": "hAP"},
|
|
"/rest/system/routerboard": {"routerboard": "false"},
|
|
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.24.4", "latest-version": "7.24.4"},
|
|
}
|
|
|
|
def handler(r: httpx.Request) -> httpx.Response:
|
|
if not up["on"]:
|
|
raise httpx.ConnectError("connection refused")
|
|
return httpx.Response(200, json=data.get(r.url.path, []))
|
|
|
|
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
|
await ops.poll_device(d.id)
|
|
assert devices.get_device(d.id).online is False
|
|
|
|
up["on"] = True
|
|
await ops.poll_device(d.id) # был offline -> полный опрос
|
|
dev = devices.get_device(d.id)
|
|
assert dev.online is True and dev.status["ros_installed"] == "7.24.4"
|
|
|
|
data["/rest/system/resource"]["uptime"] = "2m"
|
|
await ops.poll_device(d.id) # онлайн -> лёгкий опрос обновляет uptime, остальное сохраняется
|
|
dev = devices.get_device(d.id)
|
|
assert dev.status["uptime"] == "2m" and dev.status["ros_latest"] == "7.24.4"
|
|
|
|
|
|
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": "pw"})
|
|
# 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": "Bearer test-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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chr_status_and_version_compare():
|
|
"""CHR: раздела system/routerboard нет (HTTP 400) — это не сбой; «последняя» версия старше установленной — не обновление."""
|
|
def handler(r: httpx.Request) -> httpx.Response:
|
|
if r.url.path == "/rest/system/routerboard":
|
|
return httpx.Response(400, json={"detail": "no such command or directory (routerboard)", "error": 400})
|
|
data = {
|
|
"/rest/system/resource": {"board-name": "CHR Yandex epyc-9654", "version": "7.24 (stable)", "uptime": "1d"},
|
|
"/rest/system/package/update": {"channel": "long-term", "installed-version": "7.24", "latest-version": "7.23.7"},
|
|
}
|
|
return httpx.Response(200, json=data.get(r.url.path, []))
|
|
|
|
async with ros_client(handler) as c:
|
|
st = await ros.get_status(c)
|
|
assert await ros.upgrade_firmware(c) == "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
|
assert st["model"].startswith("CHR") and st["fw_current"] is None
|
|
assert not ros.version_newer(st["ros_latest"], st["ros_installed"]) # 7.23.7 старше 7.24
|
|
assert ros.version_newer("7.24.1", "7.24") and ros.version_newer("7.25", "7.25rc1") and not ros.version_newer("7.25rc1", "7.25")
|
|
|
|
|
|
def test_device_name_is_immutable():
|
|
"""Имя задаётся только при создании: API отклоняет смену, форма изменения имя игнорирует."""
|
|
with TestClient(create_app()) as c:
|
|
c.post("/login", data={"username": "admin", "password": "pw"})
|
|
h = {"Authorization": "Bearer test-token"}
|
|
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
|
|
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r2"})
|
|
assert r.status_code == 400 and "нельзя изменить" in r.text
|
|
# то же имя — допустимо (клиенты часто присылают объект целиком); остальные поля меняются
|
|
r = c.patch(f"/api/v1/devices/{d['id']}", headers=h, json={"name": "r1", "host": "10.0.0.9"})
|
|
assert r.status_code == 200 and r.json()["name"] == "r1" and r.json()["host"] == "10.0.0.9"
|
|
# форма изменения: присланное имя игнорируется, остальное сохраняется
|
|
r = c.post(f"/devices/{d['id']}/edit", data={"name": "hacked", "host": "10.0.0.7", "port": "80", "username": "u"},
|
|
follow_redirects=False)
|
|
assert r.status_code == 303
|
|
page = c.get(f"/ui/dialog/device/{d['id']}").text
|
|
assert "readonly" in page and 'name="name"' not in page
|
|
dev = devices.get_device(d["id"])
|
|
assert (dev.name, dev.host) == ("r1", "10.0.0.7")
|
|
|
|
|
|
def test_ids_are_unique_typed_and_ordered():
|
|
"""Все ID глобально уникальны: не повторяются, типы не пересекаются, внутри типа растут со временем."""
|
|
seen, last = set(), {}
|
|
for i in range(50_000):
|
|
prefix = list(ids.PREFIXES)[i % len(ids.PREFIXES)]
|
|
v = ids.new_id(prefix)
|
|
assert v not in seen and v > last.get(prefix, "")
|
|
assert ids.is_id(v, prefix) and not any(ids.is_id(v, o) for o in ids.PREFIXES if o != prefix)
|
|
seen.add(v)
|
|
last[prefix] = v
|
|
with pytest.raises(LookupError): # ID другого типа в пути/запросе отклоняется
|
|
ids.check(ids.new_id("job"), "dev")
|
|
with pytest.raises(LookupError):
|
|
devices.get_device("1") # числовые ID больше не существуют
|
|
|
|
|
|
LEGACY_DDL = """
|
|
CREATE TABLE devices (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, host VARCHAR(255) NOT NULL, port INTEGER NOT NULL,
|
|
username VARCHAR(64) NOT NULL, password_enc TEXT NOT NULL, verify_tls BOOLEAN NOT NULL, created_at DATETIME NOT NULL,
|
|
online BOOLEAN, status_json TEXT NOT NULL, status_at DATETIME, last_error TEXT, last_backup_requested_at DATETIME,
|
|
use_tls BOOLEAN NOT NULL DEFAULT 1, group_id INTEGER, note TEXT, PRIMARY KEY (id), UNIQUE (name));
|
|
CREATE TABLE jobs (id INTEGER NOT NULL, device_id INTEGER, device_name VARCHAR(64) NOT NULL, type VARCHAR(32) NOT NULL,
|
|
status VARCHAR(16) NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL, finished_at DATETIME, PRIMARY KEY (id));
|
|
CREATE TABLE backups (id INTEGER NOT NULL, device_id INTEGER NOT NULL, requested_at DATETIME NOT NULL, status VARCHAR(16) NOT NULL,
|
|
key_binary VARCHAR(512), key_rsc VARCHAR(512), error TEXT, PRIMARY KEY (id),
|
|
FOREIGN KEY(device_id) REFERENCES devices (id) ON DELETE CASCADE);
|
|
CREATE TABLE device_groups (id INTEGER NOT NULL, name VARCHAR(64) NOT NULL, PRIMARY KEY (id), UNIQUE (name));
|
|
"""
|
|
|
|
|
|
def test_migration_replaces_numeric_ids(tmp_path):
|
|
"""БД старой схемы: числовые ID заменяются на dev_/grp_/job_/bkp_, ссылки и порядок сохраняются, повтор — без эффекта."""
|
|
path = tmp_path / "legacy.db"
|
|
con = sqlite3.connect(path)
|
|
con.executescript(LEGACY_DDL)
|
|
con.executemany("INSERT INTO device_groups VALUES (?, ?)", [(1, "Alpha"), (2, "Beta")])
|
|
for i, (name, grp) in enumerate((("r1", 1), ("r2", None)), start=1):
|
|
con.execute("INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, created_at, status_json, use_tls, group_id)"
|
|
" VALUES (?,?,?,?,?,?,?,?,?,?,?)", (i, name, "10.0.0.%d" % i, 80, "u", "enc", 0, "2026-09-1%d 10:00:00.000000" % i, "{}", 0, grp))
|
|
# задачи: две по r1 (id 1), одна по уже удалённому устройству (id 99)
|
|
for i, dev in enumerate((1, 1, 99), start=1):
|
|
con.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?,?,?)", (i, dev, "n", "backup", "done", "ok", "2026-09-19 10:0%d:00.000000" % i, None))
|
|
con.execute("INSERT INTO backups VALUES (1, 1, '2026-09-19 10:00:00.000000', 'done', 'backups/r1/a.backup', 'backups/r1/a.rsc', NULL)")
|
|
con.execute("INSERT INTO backups VALUES (2, 99, '2026-09-19 10:05:00.000000', 'done', 'backups/gone/b.backup', NULL, NULL)")
|
|
con.commit()
|
|
con.close()
|
|
|
|
db.init_db(f"sqlite:///{path}")
|
|
con = sqlite3.connect(path)
|
|
assert con.execute("PRAGMA user_version").fetchone()[0] == 2
|
|
counts = {t: con.execute(f"SELECT count(*) FROM {t}").fetchone()[0] for t in ("device_groups", "devices", "jobs", "backups")}
|
|
assert counts == {"device_groups": 2, "devices": 2, "jobs": 3, "backups": 2}
|
|
alpha = con.execute("SELECT id FROM device_groups WHERE name='Alpha'").fetchone()[0]
|
|
r1, r2 = (con.execute("SELECT id, group_id FROM devices WHERE name=?", (n,)).fetchone() for n in ("r1", "r2"))
|
|
assert ids.is_id(r1[0], "dev") and r1[1] == alpha and r2[1] is None # связь устройство → группа сохранена
|
|
job_dev = [r[0] for r in con.execute("SELECT device_id FROM jobs ORDER BY id")]
|
|
assert job_dev[0] == job_dev[1] == r1[0] and job_dev[2] not in (r1[0], r2[0]) # удалённое устройство — свой стабильный ID
|
|
assert [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY created_at")] == [r[0] for r in con.execute("SELECT id FROM jobs ORDER BY id")]
|
|
bk = con.execute("SELECT device_id, device_name FROM backups ORDER BY requested_at").fetchall()
|
|
assert bk[0] == (r1[0], "r1") and bk[1][0] == job_dev[2] and bk[1][1] == "gone" # имя — из ключа S3
|
|
every = [r[0] for t in ("device_groups", "devices", "jobs", "backups", "events") for r in con.execute(f"SELECT id FROM {t}")]
|
|
assert len(every) == len(set(every)) and all(ids.is_id(i) for i in every) # ни одного пересечения между таблицами
|
|
assert con.execute("SELECT type FROM events").fetchall() == [("system.migrated",)]
|
|
assert len(list(tmp_path.glob("legacy.db.bak-*"))) == 1 # копия БД перед миграцией
|
|
con.close()
|
|
db.init_db(f"sqlite:///{path}") # повторный запуск ничего не меняет
|
|
con = sqlite3.connect(path)
|
|
assert con.execute("SELECT count(*) FROM events").fetchone()[0] == 1 and len(list(tmp_path.glob("legacy.db.bak-*"))) == 1
|
|
con.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_link_entities(monkeypatch):
|
|
"""Журнал: у каждой записи свой ID, ссылка на ID сущности и актор; в журнал попадают только смены online/offline."""
|
|
events.set_actor("api")
|
|
d = devices.create_device("r1", "10.0.0.1", 80, "u", "p")
|
|
up = {"on": False}
|
|
|
|
def handler(r: httpx.Request) -> httpx.Response:
|
|
if not up["on"]:
|
|
raise httpx.ConnectError("refused")
|
|
data = {"/rest/system/resource": {"version": "7.24", "uptime": "1m", "board-name": "hAP"},
|
|
"/rest/system/routerboard": {"routerboard": "false"},
|
|
"/rest/system/package/update": {"channel": "stable", "installed-version": "7.24", "latest-version": "7.24"}}
|
|
return httpx.Response(200, json=data.get(r.url.path, []))
|
|
|
|
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
|
await ops.poll_device(d.id)
|
|
await ops.poll_device(d.id) # повторный опрос без смены состояния — нового события нет
|
|
up["on"] = True
|
|
await ops.poll_device(d.id)
|
|
|
|
async def fake_backup(device_id):
|
|
return "готово"
|
|
|
|
monkeypatch.setitem(jobs.JOB_TYPES, "backup", fake_backup)
|
|
[job_id] = jobs.start_jobs("backup", [d.id])
|
|
await asyncio.sleep(0.3)
|
|
|
|
evs = events.list_events(device_id=d.id)
|
|
assert [e.type for e in reversed(evs)] == ["device.created", "device.offline", "device.online",
|
|
"job.created", "job.started", "job.done"]
|
|
assert all(ids.is_id(e.id, "evt") and e.actor == "api" and e.device_id == d.id for e in evs)
|
|
assert len({e.id for e in evs}) == len(evs) and [e.id for e in evs] == sorted((e.id for e in evs), reverse=True)
|
|
job_events = events.list_events(job_id=job_id)
|
|
assert {e.entity_id for e in job_events} == {job_id} and jobs.get_job(job_id).status == "done"
|
|
|
|
|
|
def test_events_api_and_id_validation():
|
|
"""API отдаёт журнал с ID; ID чужого типа и числовые ID дают 404; вход в UI попадает в журнал."""
|
|
with TestClient(create_app()) as c:
|
|
h = {"Authorization": "Bearer test-token"}
|
|
c.post("/login", data={"username": "admin", "password": "wrong"})
|
|
c.post("/login", data={"username": "admin", "password": "pw"})
|
|
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
|
|
assert ids.is_id(d["id"], "dev")
|
|
evs = c.get("/api/v1/events", headers=h, params={"entity_id": d["id"]}).json()
|
|
assert [e["type"] for e in evs] == ["device.created"] and evs[0]["actor"] == "api" and evs[0]["data"]["name"] == "r1"
|
|
assert c.get(f"/api/v1/events/{evs[0]['id']}", headers=h).json()["entity_id"] == d["id"]
|
|
auth = c.get("/api/v1/events", headers=h, params={"type": "auth"}).json()
|
|
assert sorted(e["type"] for e in auth) == ["auth.failed", "auth.login"]
|
|
assert "pw" not in json.dumps(auth) # пароли в журнал не попадают
|
|
assert c.get("/api/v1/devices/1", headers=h).status_code == 404
|
|
assert c.get(f"/api/v1/devices/{ids.new_id('job')}", headers=h).status_code == 404
|
|
assert c.get(f"/api/v1/jobs/{d['id']}", headers=h).status_code == 404
|
|
|
|
|
|
@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()
|
|
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"]
|
|
|
|
|
|
def _fill_events(n: int, *, age_days: int = 0) -> None:
|
|
with session_scope() as s:
|
|
for i in range(n):
|
|
s.add(Event(type="device.updated", entity_type="device", message=f"событие {i}", actor="system",
|
|
ts=now() - timedelta(days=age_days)))
|
|
|
|
|
|
def test_journal_rotation_by_age_and_count():
|
|
"""Ротация: по сроку, по числу записей (после неё в журнале ровно max_rows), 0 = без ограничения, валидация."""
|
|
settings.save_journal(0, 0)
|
|
_fill_events(5, age_days=100)
|
|
_fill_events(20)
|
|
assert settings.save_journal(30, 0) == {"retention_days": 30, "max_rows": 0} # срок 30 дней: старые 5 удалены
|
|
assert events.count(type_="device.updated") == 20
|
|
rotated = events.list_events(type_="journal.rotated")
|
|
assert len(rotated) == 1 and json.loads(rotated[0].data)["by_age"] == 5
|
|
|
|
_fill_events(150)
|
|
settings.save_journal(0, 100) # лимит 100: остаётся ровно 100 записей, удалены самые старые
|
|
assert events.count() == 100
|
|
newest = events.list_events(limit=1)[0]
|
|
assert newest.type == "journal.rotated" and json.loads(newest.data)["by_count"] > 0
|
|
assert events.rotate() == {"by_age": 0, "by_count": 0} # повторный запуск ничего не удаляет (нет «пилы»)
|
|
|
|
settings.save_journal(0, 0) # 0/0 — без ограничений
|
|
_fill_events(30)
|
|
assert events.rotate() == {"by_age": 0, "by_count": 0}
|
|
for bad in ((-1, 0), (0, 50), ("x", 0)):
|
|
with pytest.raises(ValueError):
|
|
settings.save_journal(*bad)
|
|
|
|
|
|
def test_journal_clear_requires_password_through_modal():
|
|
"""Очистка: только через окно с паролем пользователя; неверный пароль не очищает и ведёт к блокировке;
|
|
успех оставляет одну запись об очистке; в API очистки нет."""
|
|
htmx = {"HX-Request": "true"}
|
|
with TestClient(create_app()) as c:
|
|
h = {"Authorization": "Bearer test-token"}
|
|
c.post("/login", data={"username": "admin", "password": "pw"})
|
|
_fill_events(30)
|
|
before = events.count()
|
|
page = c.get("/ui/dialog/events-clear", headers=htmx).text
|
|
assert "Очистить журнал" in page and 'id="clear-confirm" disabled' in page # кнопка неактивна без пароля
|
|
|
|
assert c.post("/events/clear", data={"password": "pw"}).status_code == 400 # не через окно — отклонено
|
|
assert events.count() == before
|
|
r = c.post("/events/clear", data={"password": ""}, headers=htmx)
|
|
assert "Введите пароль" in r.text and events.count() == before
|
|
for left in (4, 3, 2, 1):
|
|
r = c.post("/events/clear", data={"password": "wrong"}, headers=htmx)
|
|
assert f"Осталось попыток: {left}" in r.text and "HX-Refresh" not in r.headers
|
|
r = c.post("/events/clear", data={"password": "wrong"}, headers=htmx) # 5-я неверная — блокировка
|
|
assert "Слишком много неверных попыток" in r.text
|
|
r = c.post("/events/clear", data={"password": "pw"}, headers=htmx) # верный пароль при блокировке не принимается
|
|
assert "Повторите через" in r.text and "HX-Refresh" not in r.headers
|
|
denied = events.list_events(type_="journal.clear_denied")
|
|
assert len(denied) == 5 and "wrong" not in json.dumps([e.data for e in denied]) # пароль в журнал не пишется
|
|
assert events.list_events(type_="journal.clear_locked")
|
|
|
|
security.reset_failures("admin") # окончание блокировки
|
|
r = c.post("/events/clear", data={"password": "pw"}, headers=htmx)
|
|
assert r.headers["HX-Refresh"] == "true"
|
|
left = events.list_events()
|
|
assert [e.type for e in left] == ["journal.cleared"] and left[0].actor == "ui:admin"
|
|
assert json.loads(left[0].data)["deleted"] > 30
|
|
|
|
assert c.delete("/api/v1/events", headers=h).status_code in (404, 405) # очистки через API нет
|
|
assert c.post("/api/v1/events/clear", headers=h, json={"password": "pw"}).status_code in (404, 405, 422)
|
|
|
|
|
|
def test_journal_page_filters_cursor_dialogs_and_settings():
|
|
"""Страница журнала: фильтры, «Показать ещё» по курсору, окно записи, сохранение настроек."""
|
|
htmx = {"HX-Request": "true"}
|
|
with TestClient(create_app()) as c:
|
|
h = {"Authorization": "Bearer test-token"}
|
|
c.post("/login", data={"username": "admin", "password": "pw"})
|
|
d = c.post("/api/v1/devices", headers=h, json={"name": "r1", "host": "10.0.0.1", "username": "u", "password": "p"}).json()
|
|
_fill_events(105)
|
|
page = c.get("/events").text
|
|
assert page.count('class="clickable"') == 100 and "Показать ещё 100" in page and "Показано 100 из" in page
|
|
last = re.findall(r'href="/events/(evt_[0-9a-f-]+)"', page)[-1]
|
|
more = c.get(f"/ui/events?before={last}&shown=100", headers=htmx).text
|
|
assert more.count('class="clickable"') >= 5 and 'hx-swap-oob="true"' in more and "Показать ещё" not in more
|
|
|
|
# фильтры: тип (группа), актор, устройство, поиск по ID
|
|
only_dev = c.get("/events?type=device&actor=api&device=" + d["id"]).text
|
|
assert only_dev.count('class="clickable"') == 1 and "device.created" in only_dev
|
|
assert c.get("/events?q=" + d["id"][-8:]).text.count('class="clickable"') >= 1
|
|
assert "Записей не найдено" in c.get("/events?q=нет-такого-текста").text
|
|
assert c.get("/api/v1/events", headers=h, params={"actor": "api"}).json()[0]["type"] == "device.created"
|
|
|
|
ev = events.list_events(entity_id=d["id"])[0]
|
|
dialog = c.get(f"/ui/dialog/event/{ev.id}", headers=htmx).text
|
|
shown = html.unescape(dialog) # Jinja экранирует кавычки в JSON, браузер показывает обычные
|
|
assert ev.id in shown and d["id"] in shown and '"name": "r1"' in shown and 'data-copy="' + ev.id in shown
|
|
assert c.get("/events/evt_bogus").status_code == 404 and c.get(f"/events/{ev.id}").status_code == 200
|
|
|
|
# настройки: ошибка остаётся в окне, успех — обновление страницы, значения видны в API
|
|
r = c.post("/events/settings", data={"retention_days": "10", "max_rows": "5"}, headers=htmx)
|
|
assert "Максимум записей" in r.text and "HX-Refresh" not in r.headers
|
|
r = c.post("/events/settings", data={"retention_days": "45", "max_rows": "5000"}, headers=htmx)
|
|
assert r.headers["HX-Refresh"] == "true"
|
|
assert c.get("/api/v1/events/settings", headers=h).json()["retention_days"] == 45
|
|
assert c.put("/api/v1/events/settings", headers=h, json={"retention_days": 0, "max_rows": 0}).json()["max_rows"] == 0
|
|
assert "ротация: без ограничений" in c.get("/events").text
|