fix(api): Fix S3 backup path-style configuration and improve UX.

- Fix critical S3 client configuration bug for path-style addressing
- Add toast notifications for better user feedback on S3 config operations
- Set up Toaster component in root layout for app-wide notifications
- Clean up unused imports in test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-28 22:55:15 +01:00
committed by Elisiário Couto
parent 0122913052
commit 22ec0e36b1
7 changed files with 150 additions and 126 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Cloud, TestTube } from "lucide-react"; import { Cloud, TestTube } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -55,9 +56,12 @@ export default function S3BackupConfigDrawer({
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backupSettings"] }); queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
setOpen(false); setOpen(false);
toast.success("S3 backup configuration saved successfully");
}, },
onError: (error) => { onError: (error: any) => {
console.error("Failed to update S3 backup configuration:", error); console.error("Failed to update S3 backup configuration:", error);
const message = error?.response?.data?.detail || "Failed to save S3 configuration. Please check your settings and try again.";
toast.error(message);
}, },
}); });
@@ -69,9 +73,12 @@ export default function S3BackupConfigDrawer({
}), }),
onSuccess: () => { onSuccess: () => {
console.log("S3 connection test successful"); console.log("S3 connection test successful");
toast.success("S3 connection test successful! Your configuration is working correctly.");
}, },
onError: (error) => { onError: (error: any) => {
console.error("Failed to test S3 connection:", error); console.error("Failed to test S3 connection:", error);
const message = error?.response?.data?.detail || "S3 connection test failed. Please verify your credentials and settings.";
toast.error(message);
}, },
}); });

View File

@@ -5,6 +5,7 @@ import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA"; import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck"; import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
import { Toaster } from "../components/ui/sonner";
function RootLayout() { function RootLayout() {
const { updateAvailable, updateSW, forceReload } = usePWA(); const { updateAvailable, updateSW, forceReload } = usePWA();
@@ -48,6 +49,9 @@ function RootLayout() {
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate} onUpdate={handlePWAUpdate}
/> />
{/* Toast Notifications */}
<Toaster />
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from loguru import logger from loguru import logger
from leggen.api.routes import accounts, banks, backup, notifications, sync, transactions from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
from leggen.background.scheduler import scheduler from leggen.background.scheduler import scheduler
from leggen.utils.config import config from leggen.utils.config import config
from leggen.utils.paths import path_manager from leggen.utils.paths import path_manager

View File

@@ -38,8 +38,9 @@ class BackupService:
s3_kwargs["endpoint_url"] = current_config.endpoint_url s3_kwargs["endpoint_url"] = current_config.endpoint_url
if current_config.path_style: if current_config.path_style:
s3_kwargs["config"] = boto3.client("s3").meta.config from botocore.config import Config
s3_kwargs["config"].s3 = {"addressing_style": "path"}
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
return session.client("s3", **s3_kwargs) return session.client("s3", **s3_kwargs)
@@ -58,7 +59,9 @@ class BackupService:
# Try to list objects in the bucket (limited to 1 to minimize cost) # Try to list objects in the bucket (limited to 1 to minimize cost)
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1) s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
logger.info(f"S3 connection test successful for bucket: {config.bucket_name}") logger.info(
f"S3 connection test successful for bucket: {config.bucket_name}"
)
return True return True
except NoCredentialsError: except NoCredentialsError:
@@ -66,7 +69,9 @@ class BackupService:
return False return False
except ClientError as e: except ClientError as e:
error_code = e.response["Error"]["Code"] error_code = e.response["Error"]["Code"]
logger.error(f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}") logger.error(
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
)
return False return False
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during S3 connection test: {str(e)}") logger.error(f"Unexpected error during S3 connection test: {str(e)}")
@@ -99,9 +104,7 @@ class BackupService:
# Upload database file # Upload database file
logger.info(f"Starting database backup to S3: {backup_key}") logger.info(f"Starting database backup to S3: {backup_key}")
s3_client.upload_file( s3_client.upload_file(
str(database_path), str(database_path), self.s3_config.bucket_name, backup_key
self.s3_config.bucket_name,
backup_key
) )
logger.info(f"Database backup completed successfully: {backup_key}") logger.info(f"Database backup completed successfully: {backup_key}")
@@ -126,17 +129,18 @@ class BackupService:
# List objects with backup prefix # List objects with backup prefix
response = s3_client.list_objects_v2( response = s3_client.list_objects_v2(
Bucket=self.s3_config.bucket_name, Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
Prefix="leggen_backups/"
) )
backups = [] backups = []
for obj in response.get("Contents", []): for obj in response.get("Contents", []):
backups.append({ backups.append(
"key": obj["Key"], {
"last_modified": obj["LastModified"].isoformat(), "key": obj["Key"],
"size": obj["Size"], "last_modified": obj["LastModified"].isoformat(),
}) "size": obj["Size"],
}
)
# Sort by last modified (newest first) # Sort by last modified (newest first)
backups.sort(key=lambda x: x["last_modified"], reverse=True) backups.sort(key=lambda x: x["last_modified"], reverse=True)
@@ -173,9 +177,7 @@ class BackupService:
# Download to temporary file first, then move to final location # Download to temporary file first, then move to final location
with tempfile.NamedTemporaryFile(delete=False) as temp_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file:
s3_client.download_file( s3_client.download_file(
self.s3_config.bucket_name, self.s3_config.bucket_name, backup_key, temp_file.name
backup_key,
temp_file.name
) )
# Move temp file to final location # Move temp file to final location

View File

@@ -89,5 +89,5 @@ markers = [
] ]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["apscheduler.*", "discord_webhook.*"] module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
ignore_missing_imports = true ignore_missing_imports = true

View File

@@ -1,12 +1,9 @@
"""Tests for backup API endpoints.""" """Tests for backup API endpoints."""
from unittest.mock import AsyncMock, patch from unittest.mock import patch
import pytest import pytest
from leggen.api.models.backup import BackupSettings, S3Config
from leggen.models.config import S3BackupConfig
@pytest.mark.api @pytest.mark.api
class TestBackupAPI: class TestBackupAPI:
@@ -56,7 +53,9 @@ class TestBackupAPI:
assert s3_config["enabled"] is True assert s3_config["enabled"] is True
@patch("leggen.services.backup_service.BackupService.test_connection") @patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_success(self, mock_test_connection, api_client, mock_config): def test_update_backup_settings_success(
self, mock_test_connection, api_client, mock_config
):
"""Test successful backup settings update.""" """Test successful backup settings update."""
mock_test_connection.return_value = True mock_test_connection.return_value = True
mock_config._config["backup"] = {} mock_config._config["backup"] = {}
@@ -85,7 +84,9 @@ class TestBackupAPI:
mock_test_connection.assert_called_once() mock_test_connection.assert_called_once()
@patch("leggen.services.backup_service.BackupService.test_connection") @patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_connection_failure(self, mock_test_connection, api_client, mock_config): def test_update_backup_settings_connection_failure(
self, mock_test_connection, api_client, mock_config
):
"""Test backup settings update with connection test failure.""" """Test backup settings update with connection test failure."""
mock_test_connection.return_value = False mock_test_connection.return_value = False
mock_config._config["backup"] = {} mock_config._config["backup"] = {}
@@ -124,7 +125,7 @@ class TestBackupAPI:
"endpoint_url": None, "endpoint_url": None,
"path_style": False, "path_style": False,
"enabled": True, "enabled": True,
} },
} }
response = api_client.post("/api/v1/backup/test", json=request_data) response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -152,7 +153,7 @@ class TestBackupAPI:
"endpoint_url": None, "endpoint_url": None,
"path_style": False, "path_style": False,
"enabled": True, "enabled": True,
} },
} }
response = api_client.post("/api/v1/backup/test", json=request_data) response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -173,7 +174,7 @@ class TestBackupAPI:
"endpoint_url": None, "endpoint_url": None,
"path_style": False, "path_style": False,
"enabled": True, "enabled": True,
} },
} }
response = api_client.post("/api/v1/backup/test", json=request_data) response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -215,7 +216,10 @@ class TestBackupAPI:
data = response.json() data = response.json()
assert data["success"] is True assert data["success"] is True
assert len(data["data"]) == 2 assert len(data["data"]) == 2
assert data["data"][0]["key"] == "leggen_backups/database_backup_20250101_120000.db" assert (
data["data"][0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
def test_list_backups_no_config(self, api_client, mock_config): def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration.""" """Test backup listing with no configuration."""
@@ -231,7 +235,9 @@ class TestBackupAPI:
@patch("leggen.services.backup_service.BackupService.backup_database") @patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path") @patch("leggen.utils.paths.path_manager.get_database_path")
def test_backup_operation_success(self, mock_get_db_path, mock_backup_db, api_client, mock_config): def test_backup_operation_success(
self, mock_get_db_path, mock_backup_db, api_client, mock_config
):
"""Test successful backup operation.""" """Test successful backup operation."""
from pathlib import Path from pathlib import Path

View File

@@ -2,7 +2,7 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from botocore.exceptions import ClientError, NoCredentialsError from botocore.exceptions import ClientError, NoCredentialsError
@@ -97,8 +97,12 @@ class TestBackupService:
with patch("boto3.Session") as mock_session: with patch("boto3.Session") as mock_session:
mock_client = MagicMock() mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client mock_session.return_value.client.return_value = mock_client
error_response = {"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}} error_response = {
mock_client.list_objects_v2.side_effect = ClientError(error_response, "ListObjectsV2") "Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}
}
mock_client.list_objects_v2.side_effect = ClientError(
error_response, "ListObjectsV2"
)
result = await service.test_connection(s3_config) result = await service.test_connection(s3_config)
assert result is False assert result is False
@@ -196,6 +200,7 @@ class TestBackupService:
mock_session.return_value.client.return_value = mock_client mock_session.return_value.client.return_value = mock_client
from datetime import datetime from datetime import datetime
mock_response = { mock_response = {
"Contents": [ "Contents": [
{ {