mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
340 lines
14 KiB
Python
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
|