Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
перезагрузка; API POST /devices/{id}/firmware/downgrade и
/batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.
Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
13 KiB
Python
246 lines
13 KiB
Python
import asyncio
|
||
import json
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.db import session_scope
|
||
from app.main import create_app
|
||
from app.models import Device
|
||
from app.ros import operations as ros
|
||
from app.services import devices, events, jobs, ops
|
||
from tests.conftest import ADMIN_PASSWORD, API_TOKEN
|
||
from tests.helpers import ros_client
|
||
|
||
|
||
@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"
|
||
|
||
|
||
@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": f"Bearer {API_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_run_ros_downgrade_order_and_failures(monkeypatch):
|
||
"""run_ros_downgrade: версия не совпала с подтверждённой — бэкап и install не вызываются; версия совпала —
|
||
бэкап выполняется до install (порядок); бэкап упал — install не вызывается."""
|
||
calls = []
|
||
|
||
def handler(r: httpx.Request) -> httpx.Response:
|
||
if r.url.path == "/rest/system/package/update/install":
|
||
calls.append("install")
|
||
return httpx.Response(200, json=[])
|
||
data = {"/rest/system/package/update": {"installed-version": "7.24.4", "latest-version": "7.23.7"}}
|
||
return httpx.Response(200, json=data.get(r.url.path, []))
|
||
|
||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
||
|
||
async def fake_backup_ok(device_id):
|
||
calls.append("backup")
|
||
return "Бэкап загружен в S3: k.backup, k.rsc"
|
||
|
||
async def fake_backup_fail(device_id):
|
||
calls.append("backup")
|
||
raise RuntimeError("бэкап не удался")
|
||
|
||
# 1) версия канала (7.23.7) не совпала с подтверждённой пользователем -> задача failed, ничего не вызывается
|
||
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
|
||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.6"})
|
||
await asyncio.sleep(0.2)
|
||
j = jobs.get_job(jid)
|
||
assert j.status == "failed" and "не совпадает" in j.message and calls == []
|
||
|
||
# 2) версия совпала -> бэкап выполняется до install, install вызван
|
||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
|
||
await asyncio.sleep(0.2)
|
||
j = jobs.get_job(jid)
|
||
assert j.status == "done" and calls == ["backup", "install"]
|
||
|
||
# 3) бэкап падает -> install не вызывается
|
||
calls.clear()
|
||
monkeypatch.setattr(ops, "run_backup", fake_backup_fail)
|
||
[jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"})
|
||
await asyncio.sleep(0.2)
|
||
j = jobs.get_job(jid)
|
||
assert j.status == "failed" and calls == ["backup"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fw_downgrade_order_and_failures(monkeypatch):
|
||
"""Изменение 027, по образцу test_run_ros_downgrade_order_and_failures: плата новее встроенной в ROS —
|
||
upgrade_firmware не пишет прошивку; fw_downgrade с несовпавшей версией — бэкап и запись не вызываются;
|
||
с совпавшей — порядок backup → upgrade → reboot. Заглушка после POST .../routerboard/upgrade сразу
|
||
отдаёт в /rest/log запись с FW_DONE_MSG, чтобы ожидание завершалось без опроса."""
|
||
calls = []
|
||
written = {"on": False}
|
||
|
||
def handler(r: httpx.Request) -> httpx.Response:
|
||
if r.url.path == "/rest/system/routerboard/upgrade":
|
||
calls.append("upgrade")
|
||
written["on"] = True
|
||
return httpx.Response(200, json=[])
|
||
if r.url.path == "/rest/system/reboot":
|
||
calls.append("reboot")
|
||
return httpx.Response(200, json=[])
|
||
data = {
|
||
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.24.4", "upgrade-firmware": "7.23.7"},
|
||
"/rest/system/clock": {"date": "2026-09-28", "time": "12:00:00"},
|
||
"/rest/system/resource": {"uptime": "1h"},
|
||
"/rest/log": [{".id": "*9", "time": "2026-09-28 12:00:00", "message": ros.FW_DONE_MSG}] if written["on"] else [],
|
||
}
|
||
return httpx.Response(200, json=data.get(r.url.path, []))
|
||
|
||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
||
|
||
# 1) плата (7.24.4) новее встроенной в ROS (7.23.7) — это откат, не обновление: запись не выполняется
|
||
async with ros_client(handler) as c:
|
||
msg = await ros.upgrade_firmware(c)
|
||
assert "upgrade" not in calls and "новее встроенной" in msg and "Откатить прошивку" in msg
|
||
|
||
async def fake_backup_ok(device_id):
|
||
calls.append("backup")
|
||
return "Бэкап загружен в S3: k.backup, k.rsc"
|
||
|
||
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
|
||
|
||
# 2) версия в ROS не совпала с подтверждённой -> задача failed, бэкапа и записи нет
|
||
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.6"})
|
||
await asyncio.sleep(0.2)
|
||
j = jobs.get_job(jid)
|
||
assert j.status == "failed" and "не совпадает" in j.message and calls == []
|
||
|
||
# 3) версия совпала -> порядок backup -> upgrade -> reboot
|
||
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.7"})
|
||
await asyncio.sleep(0.2)
|
||
j = jobs.get_job(jid)
|
||
assert j.status == "done" and calls == ["backup", "upgrade", "reboot"]
|
||
|
||
|
||
def test_batch_fw_downgrade_requires_target_version():
|
||
"""Изменение 027: /batch/fw_downgrade — отдельный типизированный эндпоинт, target_version обязателен."""
|
||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||
with TestClient(create_app()) as c:
|
||
h = {"Authorization": f"Bearer {API_TOKEN}"}
|
||
assert c.post("/api/v1/batch/fw_downgrade", headers=h, json={"device_ids": [d.id]}).status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_downgrade_api_endpoints(monkeypatch):
|
||
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
|
||
эндпоинт (не через общий /batch/{action}, у которого в Literal ros_downgrade нет)."""
|
||
async def fake_downgrade(device_id, target_version):
|
||
return f"откат до {target_version}"
|
||
|
||
monkeypatch.setitem(jobs.JOB_TYPES, "ros_downgrade", fake_downgrade)
|
||
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": f"Bearer {API_TOKEN}"}
|
||
assert c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={}).status_code == 422
|
||
r = c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={"target_version": "7.23.7"})
|
||
assert r.status_code == 202
|
||
job_ids = r.json()["job_ids"]
|
||
|
||
# тело без target_version — если бы запрос ушёл в общий /batch/{action} (там target_version не нужен),
|
||
# он был бы принят; 422 подтверждает, что обрабатывает именно типизированный /batch/ros_downgrade
|
||
assert c.post("/api/v1/batch/ros_downgrade", headers=h, json={"device_ids": [d1.id]}).status_code == 422
|
||
r = c.post("/api/v1/batch/ros_downgrade", headers=h,
|
||
json={"device_ids": [d1.id, d2.id], "target_version": "7.23.7"})
|
||
assert r.status_code == 202
|
||
job_ids += r.json()["job_ids"]
|
||
assert len(job_ids) == 3
|
||
|
||
await asyncio.sleep(0.3)
|
||
for jid in job_ids:
|
||
j = jobs.get_job(jid)
|
||
assert j.type == "ros_downgrade" and j.status == "done" and j.message == "откат до 7.23.7"
|
||
|
||
|
||
def test_ui_downgrade_dialog_and_mismatch(monkeypatch):
|
||
"""Окно отката: неактивная кнопка, пока версия не введена (data-enables); несовпадающая версия — ошибка
|
||
в окне, задача не создаётся."""
|
||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||
|
||
def _set_status(device, status):
|
||
with session_scope() as s:
|
||
s.get(Device, device.id).status_json = json.dumps(status)
|
||
|
||
_set_status(d, {"channel": "long-term", "ros_installed": "7.24.4", "ros_latest": "7.23.7"})
|
||
|
||
with TestClient(create_app()) as c:
|
||
c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD})
|
||
htmx = {"HX-Request": "true"}
|
||
page = c.get(f"/ui/dialog/downgrade?device_ids={d.id}", headers=htmx).text
|
||
assert "r1" in page and 'data-enables="#downgrade-confirm"' in page and 'id="downgrade-confirm" disabled' in page
|
||
|
||
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.16"}, headers=htmx)
|
||
assert "не совпадает" in r.text and 'id="modal-title"' in r.text
|
||
assert jobs.list_jobs() == []
|
||
|
||
r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.23.7"}, headers=htmx)
|
||
assert r.headers.get("HX-Refresh") == "true"
|
||
assert len(jobs.list_jobs()) == 1 and jobs.list_jobs()[0].type == "ros_downgrade"
|