fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)

The companion-credential rule has five conditions, and the second one -- "the
backup itself carried a usable credential" -- was applied to all five pairs. It
should not be. It is what stops the rule over-refusing an anonymous MQTT broker
or an anonymous LDAP bind, both of which are working configs: there, an empty
credential in the backup means the restore is not producing anything weaker
than what was backed up.

For prometheus_enabled it does not transfer. An empty prometheus_token removes
/api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
a downgrade relative to the backup -- and prometheus_token is optional, so a
backup taken on an instance that enabled Prometheus without ever setting one
carries the toggle and no usable token. That payload skipped the refusal
entirely: not a candidate, so the local-state pass never ran, and the blocklist
quietly dropped the token key. On a token-less target the result was
prometheus_enabled=true, no token row anywhere, and a full unauthenticated
metrics dump -- the same hole the rule was written to close, reached from the
likelier of the two directions.

So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
is judged on local state alone, and an availability class (mqtt, ldap, ha,
virtual_printer) that keeps it. Nothing else changes -- the local-state pass
already stands down when the instance has its own credential, when HA_TOKEN is
in the environment, and when the toggle is already on locally, so "the exposure
pre-dates this restore" still holds and refusals still get no tally increment.

One wording consequence: an exposure toggle can now be refused on a payload with
no credential-like key in it at all, where the shared caveat would have read "0
credential-like key(s) will be skipped". That case gets its own preview detail
code, settingsCompanionOnlyWillSkip, in all 13 locales.

Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
the new preview wording, and the integration test through the real endpoint for
both payloads (200 with a full metrics body before this, 404 after). Plus 3
controls, because over-refusal is still the real risk: the exposure route must
still stand down for a local token and for an already-on toggle, and the
availability class must still let a credential-less mqtt/ldap/virtual_printer
toggle through. The anonymous-broker and anonymous-bind controls are unchanged
and still pass.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:34 -04:00
parent 733894aee6
commit bbd991510f
16 changed files with 167 additions and 7 deletions
@@ -413,6 +413,40 @@ class TestRestoreDoesNotOpenTheMetricsEndpoint:
assert "bambuddy_build_info" not in response.text
assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"payload",
[
{"prometheus_enabled": "true", "currency": "EUR"},
{"prometheus_enabled": "true", "prometheus_token": "", "currency": "EUR"},
],
ids=["token-key-absent", "token-blank"],
)
async def test_a_token_less_backup_leaves_the_endpoint_shut_too(
self, async_client: AsyncClient, db_session, payload
):
"""The route the test above does not cover, and the likelier one.
``prometheus_token`` is optional, so an instance can enable Prometheus
without ever setting it. Such a backup carries the toggle and no usable
token — and because the companion rule's second condition asks whether
the *backup* had a credential, that payload used to sail straight past
the refusal and open the endpoint the case above proves shut.
"""
from backend.app.services.github_restore import _CategoryTally, github_restore_service
assert (await async_client.get("/api/v1/metrics")).status_code == 404
tally = _CategoryTally()
await github_restore_service._restore_settings(db_session, {"settings": payload}, overwrite=False, tally=tally)
await db_session.commit()
response = await async_client.get("/api/v1/metrics")
assert response.status_code == 404, "a token-less Prometheus backup opened the metrics endpoint"
assert "bambuddy_build_info" not in response.text
assert any(note["code"] == "settingsCompanionSkipped" 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(