diff --git a/packages/secubox-profiles/api/web.py b/packages/secubox-profiles/api/web.py index 1953729d..0b980751 100644 --- a/packages/secubox-profiles/api/web.py +++ b/packages/secubox-profiles/api/web.py @@ -503,8 +503,24 @@ def create_app() -> FastAPI: @app.post("/api/v1/profiles/rollback") async def rollback_active(_claims=Depends(require_jwt)): + # A rollback restores an ARBITRARY point-in-time snapshot, which has no + # associated profile name — unlike /apply, there is no "prior profile" + # to revert `active` to. On a SUCCESSFUL rollback the honest state + # afterward is "no named profile / custom": clear the pointer rather + # than leave it naming whatever profile the last /apply set, which no + # longer matches the (rolled-back) board state. Success predicate + # mirrors the CLI's own (see api/cli.py _cmd_rollback: rc 0 iff + # status in ("applied", "planned")) — "planned" is not structurally + # reachable here (the ctl is always invoked with --yes) but matching + # the CLI's exact predicate beats an ad-hoc single-value check. + root = _root() + _mod, _prof_dir, _pins, active_file = _cli._paths(root) + active_path = Path(active_file) async with _apply_lock: - return await _run_ctl_json("rollback") + report = await _run_ctl_json("rollback") + if isinstance(report, dict) and report.get("status") in ("applied", "planned"): + active_path.unlink(missing_ok=True) + return report return app diff --git a/packages/secubox-profiles/tests/test_web.py b/packages/secubox-profiles/tests/test_web.py index 3bcfaa9d..cf5e87a4 100644 --- a/packages/secubox-profiles/tests/test_web.py +++ b/packages/secubox-profiles/tests/test_web.py @@ -502,3 +502,47 @@ def test_apply_applied_keeps_active_at_target(client, root, monkeypatch): r = client.post("/api/v1/profiles/apply", json={"profile": "lite"}) assert r.status_code == 200 assert (root / "profiles" / "active").read_text().strip() == "lite" # kept + + +# --------------------------------------------------------------------------- +# rollback clears `active` — a standalone rollback restores an ARBITRARY +# point-in-time snapshot that has no associated profile name; leaving `active` +# unchanged would have it keep naming whatever profile the last /apply set, +# which no longer matches the (rolled-back) board state. Mirrors the +# apply_active revert-on-rolled_back fix above, but for the separate +# /rollback route: on a successful rollback (CLI status "applied"/"planned", +# see api/cli.py _cmd_rollback's own success predicate) the honest state is +# "no named profile" -> clear the pointer. +# --------------------------------------------------------------------------- + +def test_rollback_clears_active_pointer(client, root, monkeypatch): + assert (root / "profiles" / "active").read_text().strip() == "media" + + def fake_run(argv, **kw): + class P: + returncode = 0 + stdout = ('{"status":"applied","changed":["x"],"failed":[],' + '"rolled_back":["x"],"target":"R1"}') + stderr = "" + return P() + monkeypatch.setattr(web, "_ctl_run", fake_run) + + r = client.post("/api/v1/profiles/rollback", json={}) + assert r.status_code == 200 + assert not (root / "profiles" / "active").exists() + + +def test_rollback_failure_leaves_active_untouched(client, root, monkeypatch): + assert (root / "profiles" / "active").read_text().strip() == "media" + + def fake_run(argv, **kw): + class P: + returncode = 2 + stdout = "" + stderr = "boum" + return P() + monkeypatch.setattr(web, "_ctl_run", fake_run) + + r = client.post("/api/v1/profiles/rollback", json={}) + assert r.status_code == 500 + assert (root / "profiles" / "active").read_text().strip() == "media"