fix(backup): never restore a toggle whose credential can't come with it (#2656)

A settings restore refuses to write anything credential-shaped, but wrote the
switches that depend on those credentials like any other key. Restoring the two
halves apart is not a partial restore, it is a downgrade.

The sharp case is Prometheus. /api/v1/metrics is on PUBLIC_API_ROUTES and its
only gate is `if token:`, so an empty or absent token means no authentication at
all. prometheus_token matches the `token` hint and is refused; prometheus_enabled
is an ordinary key and was written. On an instance that never enabled Prometheus
there is no local token row, so overwrite-*off* alone was enough to publish the
whole metrics body to anyone who could reach the port. The new integration test
shows exactly that: 200 with a full unauthenticated body before, 404 after.

Four more pairs are the same shape and break an integration rather than open one:
ldap_enabled/ldap_bind_password, mqtt_enabled/mqtt_password, ha_enabled/ha_token
(with an HA_TOKEN env arm, since get_homeassistant_settings prefers the
environment over the row), and virtual_printer_enabled/virtual_printer_access_code
— the last largely vestigial post-migration, included for consistency.

A toggle is refused only when all five hold: the payload value is truthy, the
backup carried a non-empty companion credential, that credential is denylisted,
this instance has no usable value for it, and the toggle is not already on
locally. The second condition is what keeps the rule honest — an anonymous MQTT
broker and an anonymous LDAP bind are legitimate configs that pass empty
credentials straight through, and without it both would be false positives. With
it, the rule fires only when the restore would produce a config weaker than both
the backup and the local instance. A present-but-blank prometheus_token row
counts as unusable, since that is precisely the `if token:` hole.

The rule needs the payload *and* local database state, which the old static
_count_items could not see, so preview and restore now share one classifier:
_plan_settings() runs a single SELECT over both halves of every candidate pair
before anything enters the session, and returns the three refusal buckets.
preview() takes the session the route already has. _is_skipped_setting_key is
gone rather than having its docstring corrected as asked: a name is no longer
enough to decide, so the union predicate had no caller left.

Also implements the review's third ruling — the tally counts what the preview
counted, and refusals live in the notes. Two `skipped += 1` increments are
dropped (blocked, protected) and the companion refusal adds none; the value-is-
None and overwrite-off skips stay, because they depend on the run's flags, which
the preview cannot see. restored + skipped + failed now equals the item count the
user was shown — off by three before.

Behaviour change called out for review: test_credential_keys_are_never_restored
and test_auth_settings_are_never_restored asserted skipped == 2 and 4; both are
now 0, which is the point of the ruling.

16 new unit tests plus 2 integration tests. Nine of them are controls, because
over-refusal is the real risk of this change — the anonymous-broker and
anonymous-bind guards are load-bearing, not decoration.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:24 -04:00
parent 07244b6a43
commit ca93d4cf44
4 changed files with 531 additions and 34 deletions
@@ -274,3 +274,66 @@ class TestStatusExposesRestoreState:
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
assert response.json()["restore_running"] is False
class TestRestoreDoesNotOpenTheMetricsEndpoint:
"""The companion-credential rule, proved against the endpoint it protects.
``/api/v1/metrics`` is on ``PUBLIC_API_ROUTES`` and its only gate is
``if token:``, so writing ``prometheus_enabled`` onto an instance with no
``prometheus_token`` row hands the entire metrics body to anyone who can
reach the port. The restore refuses that token as credential-shaped, so
before this change the pair came apart and the endpoint opened — with
overwrite *off*, since the local row is missing rather than present.
Driven through the real service and the real endpoint against one database:
the unit tests can show the toggle is not written, only this can show what
that means.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restoring_prometheus_enabled_leaves_the_endpoint_shut(self, async_client: AsyncClient, db_session):
from backend.app.services.github_restore import _CategoryTally, github_restore_service
# An instance that never enabled Prometheus: no toggle row, no token row.
assert (await async_client.get("/api/v1/metrics")).status_code == 404
tally = _CategoryTally()
await github_restore_service._restore_settings(
db_session,
{"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret", "currency": "EUR"}},
overwrite=False,
tally=tally,
)
await db_session.commit()
response = await async_client.get("/api/v1/metrics")
assert response.status_code == 404, "a settings restore opened the metrics endpoint"
assert "bambuddy_build_info" not in response.text
assert any("switched off" in note for note in tally.notes)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_instance_with_its_own_token_still_gets_the_toggle_back(
self, async_client: AsyncClient, db_session
):
"""Control. The rule must not break a legitimate Prometheus restore."""
from backend.app.services.github_restore import _CategoryTally, github_restore_service
await async_client.put(
"/api/v1/settings/", json={"prometheus_enabled": False, "prometheus_token": "local-token"}
)
await github_restore_service._restore_settings(
db_session,
{"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret"}},
overwrite=True,
tally=_CategoryTally(),
)
await db_session.commit()
assert (await async_client.get("/api/v1/metrics")).status_code == 401
authorised = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer local-token"})
assert authorised.status_code == 200
assert "bambuddy_build_info" in authorised.text