Files
joulenap/backend/tests/test_api.py
T
Catubba 0ad459e14c fix(backend): resolve the nine findings of the backend-block review
The three silent ones first. A 0.9 config that fails to convert used to boot an
empty config that looks exactly like a fresh install: the reason now reaches the
UI (GET /api/status.config_error), the activity log and an ERROR line, and the
.bak parachute is written on the failure branch too, so a later save from the
Advanced tab cannot destroy the original.

"PBS left powered on" was wrong in both directions -- every successful run
against an always-on PBS warned, and a sync route that left its target awake did
not. lease.release() returned one False for four situations, only two of which
cost power; it now returns a ReleaseOutcome that names the reason, which becomes
both the POWEROFF step's detail and RunContext.left_on. The interrupted-run path
keeps a step-derived rule, now paired per device and filtered by managed_power.

422 bodies echoed the whole config, secrets included: a config-level validator
raises at loc=(), so pydantic attached every token, the secret key, the password
hash, the SMTP and bot tokens as the error's input. One helper with
include_input=False now serves all three config-shaped 422 sites.

Also: a redaction placeholder with nothing to resolve against is rejected instead
of silently clearing the credential (a renamed device id, or a create from a
copied body); the ad-hoc "Run verify" asks for outdated_after=0, since None meant
"only never-verified" and skipped exactly the snapshots the button exists for;
the manual power-off holds the single-run lock so it cannot cut a vzdump that
started in the check-then-act gap; _current_run_id is cleared when a run ends, so
a stop landing between two runs cannot hit the wrong one; and the pre-migration
.bak is chmod 0600 like every other secret-bearing file.

Tests: 617 passed, 2 skipped. Every finding was reproduced against the real code
before the fix, and each new test confirmed failing on the pre-fix code.
2026-08-03 00:56:40 +02:00

808 lines
30 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,
}
]
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_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
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)
return run_id
if not service.pending() and not service.is_running:
break
time.sleep(0.01)
# It already finished before we looked: take the newest run.
return client.get("/api/runs").json()[0]["id"]
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"] == []
# --- 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():
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