| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- """Integration tests for GitHub Backup API endpoints."""
- from unittest.mock import AsyncMock, patch
- import pytest
- from httpx import AsyncClient
- @pytest.fixture(autouse=True)
- def _mock_private_repo_check():
- """Default mock: test_connection returns success + confirmed private.
- POST /config and PATCH /config now refuse to save when the target repo
- isn't confirmed private (Bambuddy backups carry credentials — see
- `_enforce_private_repo` in github_backup.py routes). The default mock
- here keeps the existing test suite green; tests that need to exercise
- the public / unknown-visibility branches override this fixture inline.
- """
- 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
- class TestGitHubBackupConfigAPI:
- """Integration tests for /api/v1/github-backup endpoints."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_config_no_config(self, async_client: AsyncClient):
- """Verify getting config when none exists returns null."""
- response = await async_client.get("/api/v1/github-backup/config")
- assert response.status_code == 200
- assert response.json() is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_config(self, async_client: AsyncClient):
- """Verify GitHub backup config can be created."""
- data = {
- "repository_url": "https://github.com/test/repo",
- "access_token": "ghp_testtoken123",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "backup_spools": False,
- "backup_archives": False,
- "enabled": True,
- }
- response = await async_client.post("/api/v1/github-backup/config", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["repository_url"] == "https://github.com/test/repo"
- assert result["branch"] == "main"
- assert result["has_token"] is True
- assert result["enabled"] is True
- assert result["backup_spools"] is False
- assert result["backup_archives"] is False
- # Token should not be exposed in response
- assert "access_token" not in result
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_config_after_create(self, async_client: AsyncClient):
- """Verify getting config after creation returns the config."""
- # Create config first
- data = {
- "repository_url": "https://github.com/test/getrepo",
- "access_token": "ghp_testtoken456",
- "branch": "develop",
- "schedule_enabled": True,
- "schedule_type": "weekly",
- "backup_kprofiles": True,
- "backup_cloud_profiles": False,
- "backup_settings": True,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=data)
- # Get config
- response = await async_client.get("/api/v1/github-backup/config")
- assert response.status_code == 200
- result = response.json()
- assert result is not None
- assert result["repository_url"] == "https://github.com/test/getrepo"
- assert result["branch"] == "develop"
- assert result["schedule_type"] == "weekly"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_config_with_spools_and_archives(self, async_client: AsyncClient):
- """Verify config with spool and archive backup enabled."""
- data = {
- "repository_url": "https://github.com/test/spoolarchive",
- "access_token": "ghp_spooltoken",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": False,
- "backup_settings": False,
- "backup_spools": True,
- "backup_archives": True,
- "enabled": True,
- }
- response = await async_client.post("/api/v1/github-backup/config", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["backup_spools"] is True
- assert result["backup_archives"] is True
- assert result["backup_cloud_profiles"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_config_partial(self, async_client: AsyncClient):
- """Verify partial update of GitHub backup config."""
- # Create config first
- create_data = {
- "repository_url": "https://github.com/test/update",
- "access_token": "ghp_token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "backup_spools": False,
- "backup_archives": False,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- # Partial update
- update_data = {
- "branch": "develop",
- "schedule_enabled": True,
- }
- response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
- assert response.status_code == 200
- result = response.json()
- assert result["branch"] == "develop"
- assert result["schedule_enabled"] is True
- # Original values should be preserved
- assert result["repository_url"] == "https://github.com/test/update"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_config_enable_spools_and_archives(self, async_client: AsyncClient):
- """Verify partial update can enable spool and archive backup."""
- # Create config first
- create_data = {
- "repository_url": "https://github.com/test/updatetoggle",
- "access_token": "ghp_toggletoken",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "backup_spools": False,
- "backup_archives": False,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- # Enable spools and archives via partial update
- update_data = {
- "backup_spools": True,
- "backup_archives": True,
- }
- response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
- assert response.status_code == 200
- result = response.json()
- assert result["backup_spools"] is True
- assert result["backup_archives"] is True
- # Other values preserved
- assert result["backup_kprofiles"] is True
- assert result["backup_settings"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_config_rejects_disabling_insecure_http_for_stored_http_url(self, async_client: AsyncClient):
- """Verify PATCH rejects leaving a stored HTTP URL without explicit insecure-HTTP allowance."""
- create_data = {
- "repository_url": "http://git.example.com/test/httprepo",
- "access_token": "gitea_token",
- "branch": "main",
- "provider": "gitea",
- "allow_insecure_http": True,
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "backup_spools": False,
- "backup_archives": False,
- "enabled": True,
- }
- create_response = await async_client.post("/api/v1/github-backup/config", json=create_data)
- assert create_response.status_code == 200
- response = await async_client.patch("/api/v1/github-backup/config", json={"allow_insecure_http": False})
- assert response.status_code == 422
- assert "Allow insecure HTTP" in response.json()["detail"]
- stored_response = await async_client.get("/api/v1/github-backup/config")
- assert stored_response.status_code == 200
- stored = stored_response.json()
- assert stored["repository_url"] == "http://git.example.com/test/httprepo"
- assert stored["allow_insecure_http"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_config(self, async_client: AsyncClient):
- """Verify GitHub backup config can be deleted."""
- # Create config first
- create_data = {
- "repository_url": "https://github.com/test/delete",
- "access_token": "ghp_deletetoken",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- # Delete
- response = await async_client.delete("/api/v1/github-backup/config")
- assert response.status_code == 200
- # Verify it's deleted
- get_response = await async_client.get("/api/v1/github-backup/config")
- assert get_response.status_code == 200
- assert get_response.json() is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_config_not_found(self, async_client: AsyncClient):
- """Verify deleting non-existent config returns 404."""
- # Make sure no config exists
- await async_client.delete("/api/v1/github-backup/config")
- # Try to delete again
- response = await async_client.delete("/api/v1/github-backup/config")
- assert response.status_code == 404
- class TestGitHubBackupPrivateRepoGuard:
- """Refuse to save a config when the target repository is not private.
- Bambuddy backups contain MQTT credentials, HA/Prometheus tokens, the
- Bambu Cloud email, and printer access codes via K-profiles — they must
- never be pushed to a public or internal-visibility repository.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_config_rejects_public_repo(self, async_client: AsyncClient):
- """POST /config returns 400 when the connection test reports is_private=False."""
- with patch(
- "backend.app.services.github_backup.github_backup_service.test_connection",
- new=AsyncMock(
- return_value={
- "success": True,
- "message": "Connection successful",
- "repo_name": "test/public-repo",
- "permissions": {"push": True},
- "is_private": False,
- }
- ),
- ):
- response = await async_client.post(
- "/api/v1/github-backup/config",
- json={
- "repository_url": "https://github.com/test/public-repo",
- "access_token": "ghp_token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": True,
- "enabled": True,
- },
- )
- assert response.status_code == 400
- assert "not private" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_config_rejects_unknown_visibility(self, async_client: AsyncClient):
- """POST /config returns 400 when is_private cannot be determined (None)."""
- 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": None,
- }
- ),
- ):
- response = await async_client.post(
- "/api/v1/github-backup/config",
- json={
- "repository_url": "https://github.com/test/repo",
- "access_token": "ghp_token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": True,
- "enabled": True,
- },
- )
- assert response.status_code == 400
- assert "could not confirm" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_config_rejects_failed_connection(self, async_client: AsyncClient):
- """POST /config returns 400 when the connection test itself fails."""
- with patch(
- "backend.app.services.github_backup.github_backup_service.test_connection",
- new=AsyncMock(
- return_value={
- "success": False,
- "message": "Invalid access token",
- "repo_name": None,
- "permissions": None,
- "is_private": None,
- }
- ),
- ):
- response = await async_client.post(
- "/api/v1/github-backup/config",
- json={
- "repository_url": "https://github.com/test/repo",
- "access_token": "bad-token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": True,
- "enabled": True,
- },
- )
- assert response.status_code == 400
- assert "invalid access token" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_patch_rejects_url_change_to_public_repo(self, async_client: AsyncClient):
- """Changing the repository_url on an existing config re-checks privacy."""
- # Initial create succeeds via the default autouse mock (private).
- await async_client.post(
- "/api/v1/github-backup/config",
- json={
- "repository_url": "https://github.com/test/private-repo",
- "access_token": "ghp_token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": True,
- "enabled": True,
- },
- )
- # Now try to switch to a public repo — must be rejected.
- with patch(
- "backend.app.services.github_backup.github_backup_service.test_connection",
- new=AsyncMock(
- return_value={
- "success": True,
- "message": "Connection successful",
- "repo_name": "test/public-repo",
- "permissions": {"push": True},
- "is_private": False,
- }
- ),
- ):
- response = await async_client.patch(
- "/api/v1/github-backup/config",
- json={"repository_url": "https://github.com/test/public-repo"},
- )
- assert response.status_code == 400
- assert "not private" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_patch_skips_check_for_unrelated_fields(self, async_client: AsyncClient):
- """PATCHing a non-target field (e.g. schedule) does NOT re-run the test.
- Without this, every benign toggle would trigger a live API call.
- """
- await async_client.post(
- "/api/v1/github-backup/config",
- json={
- "repository_url": "https://github.com/test/private-repo",
- "access_token": "ghp_token",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": True,
- "enabled": True,
- },
- )
- # Replace the mock with one that would fail if called — proves the
- # PATCH didn't hit test_connection for a schedule-only change.
- mock = AsyncMock(side_effect=AssertionError("test_connection should not be called"))
- with patch(
- "backend.app.services.github_backup.github_backup_service.test_connection",
- new=mock,
- ):
- response = await async_client.patch(
- "/api/v1/github-backup/config",
- json={"schedule_enabled": True},
- )
- assert response.status_code == 200
- mock.assert_not_called()
- class TestGitHubBackupStatusAPI:
- """Integration tests for /api/v1/github-backup/status endpoint."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_status_no_config(self, async_client: AsyncClient):
- """Verify status when no config exists."""
- # Ensure no config
- await async_client.delete("/api/v1/github-backup/config")
- response = await async_client.get("/api/v1/github-backup/status")
- assert response.status_code == 200
- result = response.json()
- assert result["configured"] is False
- assert result["enabled"] is False
- assert result["is_running"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_status_with_config(self, async_client: AsyncClient):
- """Verify status when config exists."""
- # Create config
- create_data = {
- "repository_url": "https://github.com/test/status",
- "access_token": "ghp_statustoken",
- "branch": "main",
- "schedule_enabled": True,
- "schedule_type": "hourly",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- response = await async_client.get("/api/v1/github-backup/status")
- assert response.status_code == 200
- result = response.json()
- assert result["configured"] is True
- assert result["enabled"] is True
- assert result["is_running"] is False
- assert result["next_scheduled_run"] is not None
- class TestGitHubBackupLogsAPI:
- """Integration tests for /api/v1/github-backup/logs endpoints."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_logs_no_config(self, async_client: AsyncClient):
- """Verify getting logs when no config exists returns empty list."""
- # Ensure no config
- await async_client.delete("/api/v1/github-backup/config")
- response = await async_client.get("/api/v1/github-backup/logs")
- assert response.status_code == 200
- assert response.json() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_logs_with_config(self, async_client: AsyncClient):
- """Verify getting logs with config."""
- # Create config
- create_data = {
- "repository_url": "https://github.com/test/logs",
- "access_token": "ghp_logstoken",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "enabled": True,
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- response = await async_client.get("/api/v1/github-backup/logs")
- assert response.status_code == 200
- # No backups run yet, so empty list
- assert response.json() == []
- class TestGitHubBackupTriggerAPI:
- """Integration tests for /api/v1/github-backup/run endpoint."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_no_config(self, async_client: AsyncClient):
- """Verify triggering backup without config returns 404."""
- # Ensure no config
- await async_client.delete("/api/v1/github-backup/config")
- response = await async_client.post("/api/v1/github-backup/run")
- assert response.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_trigger_disabled_config(self, async_client: AsyncClient):
- """Verify triggering backup with disabled config returns 400."""
- # Create disabled config
- create_data = {
- "repository_url": "https://github.com/test/trigger",
- "access_token": "ghp_triggertoken",
- "branch": "main",
- "schedule_enabled": False,
- "schedule_type": "daily",
- "backup_kprofiles": True,
- "backup_cloud_profiles": True,
- "backup_settings": False,
- "enabled": False, # Disabled
- }
- await async_client.post("/api/v1/github-backup/config", json=create_data)
- response = await async_client.post("/api/v1/github-backup/run")
- assert response.status_code == 400
- assert "disabled" in response.json()["detail"].lower()
|