mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user