Files
Catubba 5b39f9ae1a fix(tests): remove the three races that made the backend suite flaky
The suite failed roughly one full run in five, differently each time. Three
independent causes, not one:

1. The lifespan started `missed-backup-check` and `interrupted-run-alert` as
   daemon threads that nothing joined, and the missed-run check opens a
   database session. Every test that builds an app leaked one, so a thread
   routinely outlived its test and kept using the database while the next test
   tore the engine down and built its own. They are now joined on shutdown,
   which is also the right behaviour in production: a restart no longer
   abandons an alert halfway out. The join is bounded well under Docker's stop
   grace, so a black-holing notification channel still cannot hold the process
   open. The job service's queue worker stays unjoined on purpose - it may be
   mid-backup, and blocking shutdown on a running vzdump is worse.

2. `_ensure_ready()` initialised the database lazily when the session factory
   was missing, which built a schema at `paths.db_path()` - the real database -
   from whichever caller got there first. `create_all` reflects once and then
   issues CREATEs, so two of those at the same time collide with "table X
   already exists", the error the suite reported. It now raises instead, so a
   caller that outlived its setup fails loudly rather than racing. One test was
   relying on that lazy path and gained the fixture it always needed; it was
   the only one, i.e. the only test that had been writing to the real database.

3. The 409 came from a test helper, not from the database race. `_run_route`
   waited for the run row to leave RUNNING, but the row is finalised inside the
   cycle while the power-off, the notification, the single-run lock and the
   queue's current entry all still follow. A second run of the same route
   inside that window is correctly rejected as already queued. The helper now
   waits for the queue itself to go idle.

Both races were reproduced before being fixed - the schema collision by racing
threads through a probe with no initialisation, the 409 by widening the window
with a temporary sleep, which made it fail every run - and both stop
reproducing with the fixes in place.

Two regression guards, each confirmed to fail on the pre-fix code: an autouse
fixture that diffs the live threads around every test and fails the one that
leaves any behind, and a test pinning that using the database before
initialising it raises.

636 passed, 2 skipped; 10 consecutive full runs green, with the real database
untouched.
2026-08-04 14:49:15 +02:00

936 lines
35 KiB
Python

"""REST routers: status, config, scheduler toggle, guests, runs/logs, account, dashboard.
Route and device CRUD live in test_api_routes.py / test_api_devices.py.
"""
from __future__ import annotations
import time
from datetime import UTC, datetime, timedelta
import pytest
from fakes import FakeBox, FakePve, UnreachablePve, make_deps
from fastapi.testclient import TestClient
from app import config
from app.config import load_config
from app.connectors.pve import Guest
from app.db import session_scope
from app.db.datastore_stats import get_datastore_stat, upsert_datastore_stat
from app.db.models import Run, RunKind, RunStatus, RunTrigger
from app.jobs import JobService
from app.main import create_app
@pytest.fixture
def app_ctx(temp_config, temp_db, monkeypatch):
"""Authenticated TestClient + app. Device reachability is stubbed off by default so
status tests don't touch the network; inject fake job deps per test."""
monkeypatch.setattr("app.connectors.net.tcp_reachable", lambda *a, **k: False)
app = create_app()
with TestClient(app) as client:
client.post("/api/auth/setup", json={"username": "admin", "password": "secret12"})
yield client, app
def _inject(app, **deps_kwargs):
"""Swap app.state.job_service for one wired to in-memory connector fakes.
The power lease gets a FakeBox too: without it a queued run would send real magic
packets and then sit through the wake timeout.
"""
deps, pve, pbs = make_deps(**deps_kwargs)
app.state.job_service = JobService(
app.state.config_store, deps=deps, lease_deps=FakeBox().deps()
)
return pve, pbs
def _wait_run(client, run_id, *, timeout=5.0):
"""Poll a run until it leaves RUNNING (background thread), return its final body."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = client.get(f"/api/runs/{run_id}").json()
if body["status"] != "running":
return body
time.sleep(0.02)
raise AssertionError(f"run {run_id} did not finish in {timeout}s")
# --- auth guard --------------------------------------------------------------
def test_protected_endpoints_require_auth(temp_config, temp_db):
with TestClient(create_app()) as client:
for path in ("/api/status", "/api/config", "/api/guests", "/api/runs",
"/api/logs", "/api/tasklog", "/api/routes", "/api/devices"):
assert client.get(path).status_code == 401, path
for path in ("/api/routes", "/api/routes/nightly/run", "/api/runs/1/stop",
"/api/devices/pbss", "/api/devices/pbss/pbs-01/power",
"/api/devices/pbss/pbs-01/gc", "/api/devices/pbss/pbs-01/test",
"/api/notify/test", "/api/scheduler/toggle", "/api/wizard/wol/test"):
assert client.post(path).status_code == 401, path
def test_login_locks_out_after_repeated_failures(app_ctx):
client, _app = app_ctx
for _ in range(5):
r = client.post("/api/login", json={"username": "admin", "password": "wrong-xxxx"})
assert r.status_code == 401
r = client.post("/api/login", json={"username": "admin", "password": "wrong-xxxx"})
assert r.status_code == 429
# --- status ------------------------------------------------------------------
def test_status_shape(app_ctx):
client, _app = app_ctx
body = client.get("/api/status").json()
assert body["state"] == "idle"
assert body["scheduler_enabled"] is True
assert body["running"] is None and body["queued"] == []
assert body["last_run"] is None
# One entry per configured device, for the topology.
assert [d["id"] for d in body["pves"]] == ["pve-alpha", "pve-beta"]
assert [d["id"] for d in body["pbss"]] == ["pbs-01", "pbs-02"]
assert all(d["online"] is False for d in body["pbss"]) # stubbed unreachable
def test_status_lists_the_next_run_of_every_armed_route(app_ctx):
client, _app = app_ctx
body = client.get("/api/status").json()
assert {r["route_id"] for r in body["next_runs"]} == {"nightly", "lab", "offsite"}
assert body["next_runs"][0]["route_name"] # named, so the rail needs no second call
# Soonest first: the rail renders them in order.
times = [r["at"] for r in body["next_runs"]]
assert times == sorted(times)
def test_status_reports_a_refused_config_migration(app_ctx, monkeypatch):
"""An empty config looks exactly like a fresh install, so the UI has to be told why."""
client, _app = app_ctx
assert client.get("/api/status").json()["config_error"] is None
monkeypatch.setattr(config, "MIGRATION_ERROR", "routes.0.options.bwlimit: bad")
assert client.get("/api/status").json()["config_error"] == "routes.0.options.bwlimit: bad"
def test_status_pill_says_paused_when_the_kill_switch_is_off(app_ctx):
client, app = app_ctx
app.state.config_store.update(lambda c: setattr(c.app, "scheduler_enabled", False))
body = client.get("/api/status").json()
assert body["state"] == "paused"
assert body["scheduler_enabled"] is False
def test_status_running_names_the_route_in_flight(app_ctx):
"""The pill says "Running · <route>", so the run row has to carry which one."""
client, _app = app_ctx
with session_scope() as s:
s.add(
Run(
kind=RunKind.CYCLE,
trigger=RunTrigger.SCHEDULED,
status=RunStatus.RUNNING,
started_at=datetime.now(UTC),
route_id="nightly",
route_name="Nightly",
)
)
body = client.get("/api/status").json()
assert body["state"] == "running"
assert body["running"]["route_name"] == "Nightly"
assert body["running"]["kind"] == "cycle"
def test_status_reports_the_queue(app_ctx):
client, app = app_ctx
_inject(app)
service = app.state.job_service
# Park a run so a second one stays queued behind it.
import threading
from app.db.models import RunTrigger as _T
from app.jobs.service import QueuedRun
release = threading.Event()
started = threading.Event()
def parked(_c, _s, recorder, _d):
started.set()
release.wait(timeout=5)
recorder.finish(RunStatus.SUCCESS)
service.enqueue(
QueuedRun(key="nightly", route_id="nightly", trigger=_T.MANUAL,
kind=RunKind.CYCLE, job=parked)
)
assert started.wait(timeout=5)
try:
service.run_route("offsite")
body = client.get("/api/status").json()
assert [q["key"] for q in body["queued"]] == ["offsite"]
assert body["pbss"][0]["holders"] == 1 # the lease the running route holds
finally:
release.set()
# --- config ------------------------------------------------------------------
def test_config_get_redacts_secrets(app_ctx):
client, _app = app_ctx
cfg = client.get("/api/config").json()
assert cfg["pves"][0]["api_token_secret"] == "***REDACTED***"
assert cfg["app"]["secret_key"] == "***REDACTED***"
def test_config_put_preserves_redacted_secrets_and_rearms(app_ctx, temp_config):
client, _app = app_ctx
cfg = client.get("/api/config").json()
cfg["routes"][0]["schedule"]["time"] = "03:30" # non-secret; secrets stay REDACTED
r = client.put("/api/config", json=cfg)
assert r.status_code == 200
on_disk = load_config(temp_config)
assert on_disk.routes[0].schedule.time == "03:30"
# The real secret survived the round-trip rather than being overwritten with REDACTED.
assert on_disk.pves[0].api_token_secret == "test-pve-secret"
assert on_disk.app.secret_key not in ("", "***REDACTED***")
def test_config_put_sets_new_secret(app_ctx, temp_config):
client, _app = app_ctx
cfg = client.get("/api/config").json()
cfg["pves"][0]["api_token_secret"] = "brand-new-secret"
assert client.put("/api/config", json=cfg).status_code == 200
assert load_config(temp_config).pves[0].api_token_secret == "brand-new-secret"
def test_config_put_rejects_invalid(app_ctx):
client, _app = app_ctx
cfg = client.get("/api/config").json()
cfg["routes"][0]["options"]["mode"] = "not-a-mode"
assert client.put("/api/config", json=cfg).status_code == 422
def test_config_put_rejects_an_invalid_route_cron(app_ctx, temp_config):
# A newly-set unparseable cron must 422 before persisting, not leave the route silently
# unarmed on every restart (BE-B1).
client, _app = app_ctx
cfg = client.get("/api/config").json()
cfg["routes"][0]["schedule"]["cron"] = "0 4 * *" # 4 fields
r = client.put("/api/config", json={"routes": cfg["routes"]})
assert r.status_code == 422
# Nothing was written: the route on disk is untouched.
assert load_config(temp_config).routes[0].schedule.cron == ""
def test_config_put_rejects_invalid_mac(app_ctx, temp_config):
# A newly-set malformed WoL MAC must 422 before persisting, not fail silently at wake
# time (BE-C2). "00:11:22:33:44" is only 5 octets.
client, _app = app_ctx
before = load_config(temp_config).pbss[0].mac
cfg = client.get("/api/config").json()
cfg["pbss"][0]["mac"] = "00:11:22:33:44"
r = client.put("/api/config", json={"pbss": cfg["pbss"]})
assert r.status_code == 422
assert "pbs-01" in str(r.json()["detail"]) # says which device
assert load_config(temp_config).pbss[0].mac == before # nothing written
def test_config_put_accepts_valid_mac(app_ctx, temp_config):
client, _app = app_ctx
cfg = client.get("/api/config").json()
cfg["pbss"][0]["mac"] = "aa-bb-cc-dd-ee-ff"
r = client.put("/api/config", json={"pbss": cfg["pbss"]})
assert r.status_code == 200
assert load_config(temp_config).pbss[0].mac == "aa-bb-cc-dd-ee-ff"
def test_config_put_partial_body_preserves_secrets(app_ctx, temp_config):
client, _app = app_ctx
before = load_config(temp_config)
# A partial body (only the history window) must not reset anything else.
r = client.put("/api/config", json={"maintenance": {"history": {"retention_days": 30}}})
assert r.status_code == 200
after = load_config(temp_config)
assert after.maintenance.history.retention_days == 30
assert after.pves[0].api_token_secret == before.pves[0].api_token_secret
assert after.pbss[0].api_token_secret == before.pbss[0].api_token_secret
assert after.app.secret_key == before.app.secret_key
# --- api-key management -------------------------------------------------------
def test_generate_api_key_returns_and_persists(app_ctx):
client, app = app_ctx
r = client.post("/api/config/api-key")
assert r.status_code == 200
key = r.json()["api_key"]
assert key and len(key) >= 20
assert app.state.config_store.config.app.api_key == key
def test_regenerate_api_key_replaces_old(app_ctx):
client, app = app_ctx
first = client.post("/api/config/api-key").json()["api_key"]
second = client.post("/api/config/api-key").json()["api_key"]
assert first != second
assert app.state.config_store.config.app.api_key == second
def test_delete_api_key_clears_it(app_ctx):
client, app = app_ctx
client.post("/api/config/api-key")
r = client.delete("/api/config/api-key")
assert r.status_code == 204
assert app.state.config_store.config.app.api_key == ""
def test_api_key_management_requires_auth(temp_config, temp_db):
with TestClient(create_app()) as client:
assert client.post("/api/config/api-key").status_code == 401
assert client.delete("/api/config/api-key").status_code == 401
# --- scheduler toggle --------------------------------------------------------
def test_scheduler_toggle_off_disarms_everything(app_ctx, temp_config):
client, _app = app_ctx
body = client.post("/api/scheduler/toggle", json={"enabled": False}).json()
assert body["enabled"] is False
assert body["next_runs"] == [] # the kill-switch really unarmed every route
assert load_config(temp_config).app.scheduler_enabled is False
def test_scheduler_toggle_back_on_rearms_every_route(app_ctx):
client, _app = app_ctx
client.post("/api/scheduler/toggle", json={"enabled": False})
body = client.post("/api/scheduler/toggle", json={"enabled": True}).json()
assert {r["route_id"] for r in body["next_runs"]} == {"nightly", "lab", "offsite"}
# --- guests ------------------------------------------------------------------
def test_guests_lists_one_pves_cluster(app_ctx):
client, app = app_ctx
_inject(
app,
pves={
"pve-alpha": FakePve(
guests=[Guest(vmid=100, name="ct", type="lxc", status="running", node="n1")]
)
},
)
guests = client.get("/api/guests?pve=pve-alpha").json()
assert guests == [
{
"vmid": 100,
"name": "ct",
"type": "lxc",
"status": "running",
"node": "n1",
"last_backup": None,
"pbs_ids": [],
}
]
def test_guests_require_a_pve(app_ctx):
# vmids collide across PVEs, so "all the guests" is not a question with one answer.
client, _app = app_ctx
assert client.get("/api/guests").status_code == 422
assert client.get("/api/guests?pve=nope").status_code == 404
def test_guests_include_cached_last_backup(app_ctx):
from app.db.guest_backups import upsert_last_backups
client, app = app_ctx
_inject(
app,
pves={
"pve-alpha": FakePve(
guests=[Guest(vmid=100, name="ct", type="lxc", status="running", node="n1")]
)
},
)
epoch = 1_700_000_000
with session_scope() as session:
upsert_last_backups(session, "pve-alpha", "pbs-01", {100: epoch})
guests = client.get("/api/guests?pve=pve-alpha").json()
# Served as UTC-aware (with an offset) so the frontend converts it to local time.
assert datetime.fromisoformat(guests[0]["last_backup"]) == datetime.fromtimestamp(
epoch, tz=UTC
)
def test_guests_name_every_pbs_holding_a_backup(app_ctx):
"""The homepage's guest panel chips one PBS per copy — a synced guest lists both.
Also pins that the cache is read per *PVE*: the same vmid on another PVE must not leak
its PBSs into this listing.
"""
from app.db.guest_backups import upsert_last_backups
client, app = app_ctx
_inject(
app,
pves={
"pve-alpha": FakePve(
guests=[
Guest(vmid=100, name="synced", type="lxc", status="running", node="n1"),
Guest(vmid=101, name="local", type="qemu", status="running", node="n1"),
]
)
},
)
older, newer = 1_700_000_000, 1_700_009_999
with session_scope() as session:
upsert_last_backups(session, "pve-alpha", "pbs-01", {100: older, 101: newer})
upsert_last_backups(session, "pve-alpha", "pbs-02", {100: newer})
upsert_last_backups(session, "pve-beta", "pbs-02", {100: newer})
by_vmid = {g["vmid"]: g for g in client.get("/api/guests?pve=pve-alpha").json()}
assert by_vmid[100]["pbs_ids"] == ["pbs-01", "pbs-02"]
assert by_vmid[101]["pbs_ids"] == ["pbs-01"]
# Newest across the copies, not the first row found.
assert datetime.fromisoformat(by_vmid[100]["last_backup"]) == datetime.fromtimestamp(
newer, tz=UTC
)
def test_guests_pve_unreachable_returns_502(app_ctx):
client, app = app_ctx
_inject(app, pves={"pve-alpha": UnreachablePve()})
assert client.get("/api/guests?pve=pve-alpha").status_code == 502
# --- stopping a run ----------------------------------------------------------
def test_stop_asks_the_service_to_cancel_that_run(app_ctx):
client, app = app_ctx
seen = {}
def fake_cancel(run_id, *, power_off=False):
seen["args"] = (run_id, power_off)
return True
app.state.job_service.cancel = fake_cancel
r = client.post("/api/runs/7/stop", json={"power_off": True})
assert r.status_code == 202
assert r.json() == {"run_id": 7}
assert seen["args"] == (7, True)
def test_stop_defaults_to_leaving_the_pbs_on(app_ctx):
client, app = app_ctx
seen = {}
app.state.job_service.cancel = lambda run_id, *, power_off=False: seen.update(
power_off=power_off
) or True
client.post("/api/runs/7/stop")
assert seen["power_off"] is False
def test_stop_conflicts_when_that_run_is_not_in_flight(app_ctx):
# e.g. the click landed just after the run finished — 409, not a silent no-op.
client, app = app_ctx
app.state.job_service.cancel = lambda run_id, *, power_off=False: False
assert client.post("/api/runs/7/stop").status_code == 409
# --- runs / logs -------------------------------------------------------------
def _guest(vmid: int) -> Guest:
return Guest(vmid=vmid, name=f"g{vmid}", type="lxc", status="running", node="n1")
def _run_route(client, app, route_id: str) -> int:
"""Fire a route through the API and wait for it to finish; returns the run id."""
assert client.post(f"/api/routes/{route_id}/run").status_code == 202
service = app.state.job_service
deadline = time.monotonic() + 5
run_id = None
while time.monotonic() < deadline:
current = service.current()
if current is not None and current.run_id is not None:
run_id = current.run_id
_wait_run(client, run_id)
break
if not service.pending() and not service.is_running:
break
time.sleep(0.01)
# The run *row* is finalised inside the cycle, but the service only lets go afterwards —
# power-off, notification, then the lock and `current`. Waiting on the row alone returns
# inside that window, so a second run of the same route gets 409 and the worker outlives
# the test. Wait for the queue itself to go idle.
_drain(app)
if run_id is not None:
return run_id
# It already finished before we looked: take the newest run.
return client.get("/api/runs").json()[0]["id"]
def _drain(app, timeout: float = 5) -> None:
"""Block until the job service is idle: nothing running, queued or holding the lock."""
service = app.state.job_service
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if service.current() is None and not service.pending() and not service.is_running:
return
time.sleep(0.01)
raise AssertionError("queue did not drain")
def test_run_not_found(app_ctx):
client, _app = app_ctx
assert client.get("/api/runs/999999").status_code == 404
def test_run_summary_carries_guests_ok(app_ctx):
# The history table shows how many guests a run backed up, so the summary (not just the
# detail) has to carry it — /api/runs is the only request that view makes per poll.
client, app = app_ctx
_inject(app, pves={"pve-alpha": FakePve(guests=[_guest(100)]),
"pve-beta": FakePve(guests=[_guest(200)])})
run_id = _run_route(client, app, "nightly")
summary = next(r for r in client.get("/api/runs").json() if r["id"] == run_id)
assert summary["guests_ok"] == client.get(f"/api/runs/{run_id}").json()["guests_ok"]
assert summary["guests_ok"] is not None
assert summary["route_id"] == "nightly" # the history table's route column
def test_tasklog_empty_when_nothing_ran(app_ctx):
client, _app = app_ctx
assert client.get("/api/tasklog").json() == {"run_id": None, "lines": []}
def test_tasklog_returns_lines_and_supports_after_cursor(app_ctx):
client, app = app_ctx
pve = FakePve(guests=[_guest(100)], log_lines=["INFO: creating vzdump", "VM 100: done"])
_inject(app, pves={"pve-alpha": pve, "pve-beta": FakePve(guests=[_guest(200)])})
run_id = _run_route(client, app, "nightly")
body = client.get("/api/tasklog").json()
assert body["run_id"] == run_id
texts = [line["text"] for line in body["lines"]]
assert "INFO: creating vzdump" in texts and "VM 100: done" in texts
# A multi-source route labels its steps per PVE, so the panel can group them.
assert all(line["source"] == "pve" for line in body["lines"])
assert {line["step"] for line in body["lines"]} <= {"backup:pve-alpha", "backup:pve-beta"}
# `after` the last id returns no further lines (incremental polling is a no-op when idle).
last_id = body["lines"][-1]["id"]
assert client.get(f"/api/tasklog?after={last_id}").json()["lines"] == []
def test_tasklog_can_read_an_older_run_by_id(app_ctx):
"""The homepage expands a *finished* history row, which is never the newest run."""
client, app = app_ctx
pve = FakePve(guests=[_guest(100)], log_lines=["first run line"])
_inject(app, pves={"pve-alpha": pve, "pve-beta": FakePve(guests=[_guest(200)])})
first = _run_route(client, app, "nightly")
pve.log_lines = ["second run line"]
second = _run_route(client, app, "nightly")
# The default cursor follows the newest run...
assert client.get("/api/tasklog").json()["run_id"] == second
# ...and `run` overrides it without disturbing the response shape.
body = client.get(f"/api/tasklog?run={first}").json()
assert body["run_id"] == first
assert "first run line" in [line["text"] for line in body["lines"]]
# A run that logged nothing echoes its own id rather than null: the caller named it.
assert client.get("/api/tasklog?run=99999").json() == {"run_id": 99999, "lines": []}
# --- per-device status enrichment --------------------------------------------
def test_status_includes_datastore_and_load_when_online(app_ctx, monkeypatch):
client, app = app_ctx
_inject(app)
monkeypatch.setattr("app.connectors.net.tcp_reachable", lambda *a, **k: True)
body = client.get("/api/status").json()
first = body["pbss"][0]
assert first["online"] is True
assert first["datastore"]["total"] == 8_000_000_000
assert first["load"] == {"cpu": 7, "mem": 38, "uptime": 3600}
def test_status_omits_datastore_when_offline(app_ctx):
client, _app = app_ctx # fixture stubs reachability to False
first = client.get("/api/status").json()["pbss"][0]
assert first["datastore"] is None and first["load"] is None
def test_status_datastore_from_cache_when_offline(app_ctx):
# The whole point of Joulenap: the box is off most of the time, and the dashboard still
# has to say how full it was.
client, _app = app_ctx
with session_scope() as s:
upsert_datastore_stat(s, "pbs-01", "backup", 8_000_000_000, 2_000_000_000)
pbss = {d["id"]: d for d in client.get("/api/status").json()["pbss"]}
assert pbss["pbs-01"]["datastore"] == {
"used": 2_000_000_000,
"total": 8_000_000_000,
"used_pct": 25.0,
}
assert pbss["pbs-01"]["load"] is None # live-only, stays null when the PBS is offline
assert pbss["pbs-02"]["datastore"] is None # a different box, its own cache row
def test_probe_skips_a_device_with_no_host(temp_db):
from app.api._probe import probe_pbss
from app.config import Config, PbsDevice
cfg = Config()
cfg.pbss = [PbsDevice(id="pbs-01", managed_power=False)] # no host => never probes
probes = probe_pbss(cfg, connect=lambda _d: None)
assert probes["pbs-01"].online is False
assert probes["pbs-01"].datastore is None and probes["pbs-01"].load is None
def test_resolve_datastore_live_upserts_under_the_device_id(temp_db):
from app.api._probe import resolve_datastore
from app.config import PbsDevice
from app.connectors.pbs import DatastoreStatus
device = PbsDevice(id="pbs-01", datastore="backup", managed_power=False)
view = resolve_datastore(device, DatastoreStatus(total=10, used=4, avail=6))
assert (view.total, view.used) == (10, 4)
with session_scope() as s:
row = get_datastore_stat(s, "pbs-01", "backup")
assert row is not None and row.used == 4 # live reading was persisted, keyed by device
def test_resolve_datastore_offline_uses_that_devices_cache(temp_db):
from app.api._probe import resolve_datastore
from app.config import PbsDevice
device = PbsDevice(id="pbs-01", datastore="backup", managed_power=False)
other = PbsDevice(id="pbs-02", datastore="offsite", managed_power=False)
with session_scope() as s:
upsert_datastore_stat(s, "pbs-01", "backup", 8, 2)
view = resolve_datastore(device, None)
assert (view.total, view.used, view.used_pct) == (8, 2, 25.0)
assert resolve_datastore(other, None) is None # not one shared row for every box
# --- account -----------------------------------------------------------------
def test_account_update_changes_username_and_password(app_ctx, temp_config):
client, _app = app_ctx
r = client.put(
"/api/account",
json={"current_password": "secret12", "username": "newadmin", "password": "freshpass"},
)
assert r.status_code == 200 and r.json() == {"username": "newadmin"}
cfg = load_config(temp_config)
assert cfg.app.auth.username == "newadmin"
# New password takes effect for login.
client.post("/api/logout")
login = client.post("/api/login", json={"username": "newadmin", "password": "freshpass"})
assert login.status_code == 200
def test_account_update_empty_password_keeps_current(app_ctx, temp_config):
client, _app = app_ctx
r = client.put(
"/api/account", json={"current_password": "secret12", "username": "admin2", "password": ""}
)
assert r.status_code == 200
client.post("/api/logout")
# Old password still valid under the new username => password unchanged.
login = client.post("/api/login", json={"username": "admin2", "password": "secret12"})
assert login.status_code == 200
def test_account_update_omitted_password_keeps_current(app_ctx, temp_config):
client, _app = app_ctx
# Password key entirely absent (not just "") also means "keep current".
r = client.put("/api/account", json={"current_password": "secret12", "username": "admin4"})
assert r.status_code == 200
client.post("/api/logout")
login = client.post("/api/login", json={"username": "admin4", "password": "secret12"})
assert login.status_code == 200
def test_account_update_short_password_rejected(app_ctx, temp_config):
client, _app = app_ctx
r = client.put(
"/api/account",
json={"current_password": "secret12", "username": "admin", "password": "ab"},
)
assert r.status_code == 422
def test_account_update_wrong_current_password_rejected(app_ctx, temp_config):
client, _app = app_ctx
# A valid session alone must not be enough to rotate credentials (BE-S9).
r = client.put(
"/api/account",
json={"current_password": "wrong-pass", "username": "hacker", "password": "takeover1"},
)
assert r.status_code == 401
# Nothing changed: the original credentials still work.
cfg = load_config(temp_config)
assert cfg.app.auth.username == "admin"
client.post("/api/logout")
login = client.post("/api/login", json={"username": "admin", "password": "secret12"})
assert login.status_code == 200
def test_account_update_missing_current_password_rejected(app_ctx, temp_config):
client, _app = app_ctx
r = client.put("/api/account", json={"username": "admin", "password": "newpass-88"})
assert r.status_code == 422
def test_password_change_keeps_acting_session_but_revokes_others(app_ctx):
client, app = app_ctx
# A second, independent session established before the change.
with TestClient(app) as other:
login = other.post("/api/login", json={"username": "admin", "password": "secret12"})
assert login.status_code == 200
assert other.get("/api/auth/me").status_code == 200
# Acting client changes the password.
r = client.put(
"/api/account",
json={"current_password": "secret12", "username": "admin", "password": "newpass-88"},
)
assert r.status_code == 200
# Acting session is kept alive (cookie re-issued with the new hash).
assert client.get("/api/auth/me").status_code == 200
# The other pre-existing session is revoked (its pwv no longer matches the new hash).
assert other.get("/api/auth/me").status_code == 401
# --- dashboard integration ---------------------------------------------------
def _enable_api_key(app, key="dash-key-123"):
app.state.config_store.update(lambda c: setattr(c.app, "api_key", key))
return key
def test_dashboard_403_when_no_key_configured(app_ctx):
client, app = app_ctx
app.state.config_store.update(lambda c: setattr(c.app, "api_key", ""))
assert client.get("/api/dashboard").status_code == 403
def test_dashboard_401_without_header(app_ctx):
client, app = app_ctx
_enable_api_key(app)
assert client.get("/api/dashboard").status_code == 401
def test_dashboard_401_with_wrong_key(app_ctx):
client, app = app_ctx
_enable_api_key(app, "right-key")
r = client.get("/api/dashboard", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 401
def test_dashboard_200_with_header_key(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
r = client.get("/api/dashboard", headers={"X-API-Key": key})
assert r.status_code == 200
body = r.json()
assert set(body) == {"state", "routes", "pbss"}
assert body["state"] == "idle"
# One entry per route and per PBS — there is no single "next run" or "datastore" now.
assert [e["id"] for e in body["routes"]] == ["nightly", "lab", "offsite"]
assert [e["id"] for e in body["pbss"]] == ["pbs-01", "pbs-02"]
nightly = body["routes"][0]
assert nightly["kind"] == "backup" and nightly["enabled"] is True
assert nightly["next_run"] is not None
assert nightly["last_run_status"] == "never" and nightly["last_run_time"] is None
# PBS stubbed offline, nothing cached yet:
assert body["pbss"][0]["state"] == "sleeping"
assert body["pbss"][0]["datastore_used_pct"] is None
def test_dashboard_datastore_from_cache_when_offline(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
with session_scope() as s:
upsert_datastore_stat(s, "pbs-01", "backup", 8_000_000_000, 2_000_000_000)
entry = client.get("/api/dashboard", headers={"X-API-Key": key}).json()["pbss"][0]
assert entry["datastore_used_pct"] == 25.0
assert entry["datastore_used_bytes"] == 2_000_000_000
assert entry["datastore_total_bytes"] == 8_000_000_000
def test_dashboard_upserts_and_returns_live_when_pbs_online(app_ctx, monkeypatch):
client, app = app_ctx
key = _enable_api_key(app)
monkeypatch.setattr("app.connectors.net.tcp_reachable", lambda *a, **k: True)
_inject(app) # deps.connect_pbs -> FakePbs (datastore 8e9/2e9)
entry = client.get("/api/dashboard", headers={"X-API-Key": key}).json()["pbss"][0]
assert entry["state"] == "online"
assert entry["datastore_used_pct"] == 25.0
assert entry["datastore_used_bytes"] == 2_000_000_000
# the live reading was persisted to the cache (write-on-GET)
with session_scope() as s:
row = get_datastore_stat(s, "pbs-01", "backup")
assert row is not None and row.used == 2_000_000_000
def test_dashboard_reports_scheduler_paused(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
client.post("/api/scheduler/toggle", json={"enabled": False}) # goes through rearm
body = client.get("/api/dashboard", headers={"X-API-Key": key}).json()
assert body["state"] == "paused"
assert all(e["next_run"] is None for e in body["routes"])
def test_dashboard_200_with_query_param_key(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
r = client.get(f"/api/dashboard?key={key}")
assert r.status_code == 200
assert r.json()["pbss"][0]["state"] == "sleeping"
def test_dashboard_401_with_non_ascii_key(app_ctx):
client, app = app_ctx
_enable_api_key(app)
r = client.get("/api/dashboard?key=%C3%A9")
assert r.status_code == 401
def _add_cycle_run(session, status: RunStatus, *, started_at: datetime, route="nightly") -> Run:
run = Run(
kind=RunKind.CYCLE,
trigger=RunTrigger.SCHEDULED,
status=status,
started_at=started_at,
route_id=route,
route_name=route,
)
session.add(run)
session.flush()
return run
def test_dashboard_last_run_reflects_the_last_finished_run_not_an_in_progress_one(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
now = datetime.now(UTC)
with session_scope() as s:
_add_cycle_run(s, RunStatus.SUCCESS, started_at=now - timedelta(hours=1))
_add_cycle_run(s, RunStatus.RUNNING, started_at=now)
body = client.get("/api/dashboard", headers={"X-API-Key": key}).json()
nightly = body["routes"][0]
assert nightly["last_run_status"] == "success"
assert datetime.fromisoformat(nightly["last_run_time"]) == now - timedelta(hours=1)
def test_dashboard_last_run_is_per_route(app_ctx):
# A failed sync must not make the nightly backup look failed, and vice versa.
client, app = app_ctx
key = _enable_api_key(app)
now = datetime.now(UTC)
with session_scope() as s:
_add_cycle_run(s, RunStatus.SUCCESS, started_at=now - timedelta(hours=2))
_add_cycle_run(
s, RunStatus.FAILURE, started_at=now - timedelta(hours=1), route="offsite"
)
body = client.get("/api/dashboard", headers={"X-API-Key": key}).json()
routes = {e["id"]: e for e in body["routes"]}
assert routes["nightly"]["last_run_status"] == "success"
assert routes["offsite"]["last_run_status"] == "failed"
assert routes["lab"]["last_run_status"] == "never"
def test_dashboard_last_run_never_when_only_a_running_one_exists(app_ctx):
client, app = app_ctx
key = _enable_api_key(app)
with session_scope() as s:
_add_cycle_run(s, RunStatus.RUNNING, started_at=datetime.now(UTC))
nightly = client.get("/api/dashboard", headers={"X-API-Key": key}).json()["routes"][0]
assert nightly["last_run_status"] == "never"
assert nightly["last_run_time"] is None
def test_run_history_renders_the_error_in_the_configured_language(app_ctx):
"""The same failure text is shown in the notification *and* here, so it is stored as a
key + params and rendered on read — the UI prints whatever this endpoint returns."""
client, app = app_ctx
with session_scope() as session:
run = Run(
kind=RunKind.CYCLE,
trigger=RunTrigger.MANUAL,
status=RunStatus.FAILURE,
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
error="route 'nightly': pbs 'pbs-01' no longer exists",
error_key="pbs_missing",
error_params='{"route": "nightly", "pbs": "pbs-01"}',
)
session.add(run)
session.flush()
run_id = run.id
assert "no longer exists" in client.get(f"/api/runs/{run_id}").json()["error"]
store = app.state.config_store
cfg = store.config.model_copy(deep=True)
cfg.app.language = "it"
store.replace(cfg)
for body in (
client.get(f"/api/runs/{run_id}").json(),
next(r for r in client.get("/api/runs").json() if r["id"] == run_id),
):
assert body["error"] == "route 'nightly': il pbs 'pbs-01' non esiste più"
def test_run_history_falls_back_to_the_stored_english_error(app_ctx):
"""A pre-1.0 row has no key at all; it must still show its message, not an empty cell."""
client, _ = app_ctx
with session_scope() as session:
run = Run(
kind=RunKind.CYCLE,
trigger=RunTrigger.MANUAL,
status=RunStatus.FAILURE,
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
error="vzdump exited with code 255",
)
session.add(run)
session.flush()
run_id = run.id
assert client.get(f"/api/runs/{run_id}").json()["error"] == "vzdump exited with code 255"