Files
ros_control/tests/test_app.py
T
ayurishchevandClaude Opus 5.5 123b5abdfc Ревью кодовой базы и исправления корректности по его итогам
Ревью кодовой базы: docs/reviews/2026-09-27-codebase-review.md.

Корректность и согласованность, пункты 5–7 ревью (docs/changes/018):
- одиночное удаление бэкапа в UI идёт через общий delete_many: пометка
  deleted_at и событие backup.deleted, как у группового удаления и API;
- единая система миграций: ручные ALTER из db._migrate перенесены в
  migrations.run (при user_version < 1, до замены ID);
- групповая смена канала выполняется фоновыми задачами set_channel;
  PUT /api/v1/batch/channel → 202 {"job_ids": [...]} (ломающее изменение
  API), меню «Канал» в UI выводит задачи в панель «Задачи».

Тесты: 22 из 22. Стенд проверен на порту 8001 (8000 занят посторонним
процессом), боевые данные не изменены. Ручная проверка UI пользователем
на момент коммита не подтверждена.

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

638 lines
38 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
# 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_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()
LEGACY_DDL_NO_EXTRA_COLUMNS = """
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,
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_adds_legacy_columns_before_id_migration(tmp_path):
"""Схема до появления групп/TLS-настроек/примечаний (без use_tls/group_id/note): колонки добавляются
раньше миграции ID — _to_v1 читает их из старой таблицы."""
path = tmp_path / "legacy2.db"
con = sqlite3.connect(path)
con.executescript(LEGACY_DDL_NO_EXTRA_COLUMNS)
con.execute("INSERT INTO devices (id, name, host, port, username, password_enc, verify_tls, created_at, status_json)"
" VALUES (1, 'r1', '10.0.0.1', 80, 'u', 'enc', 0, '2026-09-10 10:00:00.000000', '{}')")
con.commit()
con.close()
db.init_db(f"sqlite:///{path}")
con = sqlite3.connect(path)
assert con.execute("PRAGMA user_version").fetchone()[0] == 2
dev_id, use_tls, group_id, note = con.execute("SELECT id, use_tls, group_id, note FROM devices").fetchone()
assert ids.is_id(dev_id, "dev") and (use_tls, group_id, note) == (1, None, None) # значения по умолчанию
con.close()
@pytest.mark.asyncio
async def test_batch_channel_runs_as_jobs(monkeypatch):
"""Групповая смена канала — фоновыми задачами: 202 + job_ids, задачи завершаются done, канал — в data job.created."""
calls = []
async def fake_set_channel(device_id, channel):
calls.append((device_id, channel))
monkeypatch.setattr(ops, "set_channel", fake_set_channel)
d1 = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
d2 = devices.create_device("r2", "10.0.0.2", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": "Bearer test-token"}
r = c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id, d2.id], "channel": "testing"})
assert r.status_code == 202
job_ids = r.json()["job_ids"]
assert len(job_ids) == 2
await asyncio.sleep(0.3)
assert sorted(calls) == sorted([(d1.id, "testing"), (d2.id, "testing")])
for jid in job_ids:
assert jobs.get_job(jid).status == "done"
created = [e for e in events.list_events(type_="job.created") if e.entity_id in job_ids]
assert len(created) == 2 and all(json.loads(e.data) == {"type": "set_channel", "channel": "testing"} for e in created)
# неверный канал отклоняется до создания задач
assert c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id], "channel": "bogus"}).status_code == 422
@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