Files
bambuddy/backend/tests/integration/test_github_restore_api.py
T
jmoore-skild ca93d4cf44 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.
2026-08-04 08:57:24 -04:00

340 lines
14 KiB
Python

"""Integration tests for the Git backup restore API endpoints (#2656)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
@pytest.fixture(autouse=True)
def _mock_private_repo_check():
"""POST /config refuses to save unless the repo is confirmed private."""
with patch(
"backend.app.services.github_backup.github_backup_service.test_connection",
new=AsyncMock(
return_value={
"success": True,
"message": "Connection successful",
"repo_name": "test/repo",
"permissions": {"push": True},
"is_private": True,
}
),
) as m:
yield m
async def _create_config(async_client: AsyncClient) -> dict:
response = await async_client.post(
"/api/v1/github-backup/config",
json={
"repository_url": "https://github.com/test/repo",
"access_token": "ghp_testtoken123",
"branch": "main",
"backup_kprofiles": True,
"backup_spools": True,
"backup_archives": True,
"backup_settings": True,
"enabled": True,
},
)
assert response.status_code == 200
return response.json()
class TestCommitsEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 404
assert "Configure backup first" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
await _create_config(async_client)
commits = [
{"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
]
with patch(
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["branch"] == "main"
assert body["commits"][0]["sha"] == "aaa1111"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
await _create_config(async_client)
with patch(
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 200
assert response.json()["success"] is False
assert response.json()["commits"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_limit_is_bounded(self, async_client: AsyncClient):
await _create_config(async_client)
assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
class TestPreviewEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/github-backup/restore/preview")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
await _create_config(async_client)
preview = {
"success": True,
"message": "OK",
"ref": "aaa1111",
"commit": None,
"metadata_version": "1.0",
"categories": [
{"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
{"category": "settings", "available": True, "item_count": 12, "detail": None},
{"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
{"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
],
}
with patch(
"backend.app.services.github_restore.github_restore_service.preview",
new=AsyncMock(return_value=preview),
):
response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
assert response.status_code == 200
body = response.json()
assert body["metadata_version"] == "1.0"
by_name = {c["category"]: c for c in body["categories"]}
assert by_name["kprofiles"]["available"] is False
assert by_name["spools"]["item_count"] == 4
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
await _create_config(async_client)
response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_to_head(self, async_client: AsyncClient):
await _create_config(async_client)
mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
response = await async_client.get("/api/v1/github-backup/restore/preview")
assert response.status_code == 200
assert mock.await_args.kwargs["ref"] == "HEAD"
class TestRestoreEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_applies_selected_categories(self, async_client: AsyncClient):
await _create_config(async_client)
outcome = {
"success": True,
"message": "Restored 5 item(s) from aaa1111",
"log_id": 3,
"ref": "aaa1111",
"results": {
"spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
"settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]},
},
}
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value=outcome),
) as mock:
response = await async_client.post(
"/api/v1/github-backup/restore",
json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
)
assert response.status_code == 200
body = response.json()
assert body["results"]["spools"]["restored"] == 4
assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"]
assert mock.await_args.kwargs["overwrite_existing"] is True
assert mock.await_args.kwargs["ref"] == "aaa1111"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_empty_category_list(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_unknown_category(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_malformed_ref(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post(
"/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
"""The safe default: a restore only inserts what's missing."""
await _create_config(async_client)
mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 200
assert mock.await_args.kwargs["overwrite_existing"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
await _create_config(async_client)
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(
return_value={
"success": False,
"message": "A backup is currently running. Wait for it to finish before restoring.",
"results": {},
}
),
):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 200
assert response.json()["success"] is False
assert "backup is currently running" in response.json()["message"]
class TestStatusExposesRestoreState:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
assert response.json()["restore_running"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restore_running_is_reported(self, async_client: AsyncClient):
"""The UI disables both action buttons off this flag."""
await _create_config(async_client)
from backend.app.services.github_restore import github_restore_service
github_restore_service._running_restore = True
github_restore_service._progress = "Restoring spool inventory..."
try:
response = await async_client.get("/api/v1/github-backup/status")
finally:
github_restore_service._running_restore = False
github_restore_service._progress = None
assert response.json()["restore_running"] is True
assert response.json()["progress"] == "Restoring spool inventory..."
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
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