Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener, optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch „Update aus dem Webinterface (optional)“: - **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige, automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“ (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater` in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“, „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten. Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images. - `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`, Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten zusätzlich `opnexus-updater`. - Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
177 lines
7.6 KiB
Python
177 lines
7.6 KiB
Python
import httpx
|
|
import pytest
|
|
|
|
from tests.test_users import _login_as
|
|
|
|
from app.config import settings
|
|
from app.services import update_check_service
|
|
from app.version import APP_VERSION
|
|
|
|
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
|
|
UPDATER = "http://updater.test:8080"
|
|
|
|
|
|
def _bump(minor=0, patch=0, major=0):
|
|
a, b, c = (int(x) for x in APP_VERSION.split("."))
|
|
return f"{a + major}.{b + minor}.{c + patch}"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(monkeypatch, tmp_path):
|
|
token = tmp_path / "token"
|
|
token.write_text("t" * 40)
|
|
monkeypatch.setattr(settings, "update_check_url", FEED)
|
|
monkeypatch.setattr(settings, "updater_url", UPDATER)
|
|
monkeypatch.setattr(settings, "updater_token_file", str(token))
|
|
update_check_service.reset_cache()
|
|
yield
|
|
update_check_service.reset_cache()
|
|
|
|
|
|
def _feed(respx_mock, version):
|
|
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
|
|
|
|
|
|
def _updater_status(respx_mock, **over):
|
|
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
|
|
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
|
|
"last_update": None}
|
|
body.update(over)
|
|
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_reports_update_and_can_update(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
_updater_status(respx_mock)
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is True and body["can_update"] is True
|
|
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
|
_feed(respx_mock, _bump(minor=1))
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
|
|
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_unreachable_updater(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is False and body["reason"] == "unreachable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
|
|
_feed(respx_mock, _bump(major=1))
|
|
_updater_status(respx_mock)
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["major_change"] is True and body["can_update"] is False
|
|
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
|
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_build_install_cannot_update(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
_updater_status(respx_mock, mode="local")
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["can_update"] is False and body["mode"] == "local"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_forwards_only_the_feed_version(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.status_code == 202
|
|
sent = route.calls.last.request
|
|
assert sent.headers["authorization"] == "Bearer " + "t" * 40
|
|
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
|
|
async def test_update_rejects_any_other_version(client, respx_mock, version):
|
|
_feed(respx_mock, _bump(patch=1))
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": version})
|
|
assert resp.status_code in (409, 422)
|
|
assert route.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_when_no_update_available(client, respx_mock):
|
|
_feed(respx_mock, APP_VERSION)
|
|
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
|
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
route = respx_mock.post(f"{UPDATER}/update")
|
|
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
|
|
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_is_forwarded(client, respx_mock):
|
|
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
|
|
resp = await client.post("/api/system/updater/rollback")
|
|
assert resp.status_code == 202 and route.call_count == 1
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
status = _updater_status(respx_mock)
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
|
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
|
|
viewer = await _login_as("viewer-upd", "password-123456")
|
|
try:
|
|
assert (await viewer.get("/api/system/updater")).status_code == 403
|
|
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
|
|
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
|
|
finally:
|
|
await viewer.aclose()
|
|
assert status.call_count == 0 and route.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_anonymous_is_rejected(anon_client):
|
|
assert (await anon_client.get("/api/system/updater")).status_code == 401
|
|
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_request_is_audited(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
|
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
|
|
events = (await client.get("/api/audit?kind=system")).json()["items"]
|
|
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
|