Files
ss-tools/backend/tests/plugins/test_storage_plugin.py

540 lines
23 KiB
Python

# #region Test.StoragePlugin [C:3] [TYPE Module] [SEMANTICS test, storage, plugin, filesystem]
# @BRIEF Unit tests for StoragePlugin — properties, path resolution, file operations, and edge cases.
# @RELATION BINDS_TO -> [Plugin.StoragePlugin]
# @TEST_EDGE: path_traversal -> validate_path raises ValueError
# @TEST_EDGE: missing_variable -> resolve_path falls back to stripped literal
# @TEST_EDGE: category_mismatch -> delete_file/get_file_path raise ValueError
# @TEST_EDGE: file_not_found -> delete_file/get_file_path raise FileNotFoundError
# @TEST_EDGE: missing_category -> list_files root view returns category directories
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import tempfile
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
from src.plugins.storage.plugin import StoragePlugin
from src.models.storage import FileCategory, StoredFile
# ── Helpers ──
def _mock_config_manager(storage_root: str | None = None):
"""Create a mock ConfigManager with storage root settings."""
cm = MagicMock()
cfg = MagicMock()
if storage_root:
cfg.settings.storage.root_path = storage_root
else:
cfg.settings.storage.root_path = tempfile.mkdtemp(prefix="storage_plugin_")
cm.get_config.return_value = cfg
return cm
def _mock_task_context():
ctx = MagicMock()
ctx.logger = MagicMock()
ctx.logger.with_source.return_value = MagicMock()
return ctx
class TestStoragePluginProperties:
"""Verify static property values."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_id(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
assert plugin.id == "storage-manager"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_name(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
assert plugin.name == "Storage Manager"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_description(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
assert plugin.description == "Manages local file storage for backups and repositories."
@patch("src.plugins.storage.plugin.get_config_manager")
def test_version(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
assert plugin.version == "1.0.0"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_ui_route(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
assert plugin.ui_route == "/tools/storage"
class TestStoragePluginGetSchema:
"""Verify get_schema output."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_get_schema(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
schema = plugin.get_schema()
assert schema["type"] == "object"
assert "category" in schema["properties"]
assert schema["properties"]["category"]["type"] == "string"
assert schema["properties"]["category"]["enum"] == [c.value for c in FileCategory]
assert schema["required"] == ["category"]
class TestStoragePluginExecute:
"""Verify StoragePlugin.execute with various scenarios."""
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_execute_basic(self, mock_get_cm):
"""Execute with basic params."""
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
result = await plugin.execute({"category": "backups"})
assert result is None
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_execute_with_context(self, mock_get_cm):
"""Execute with TaskContext — uses context.logger."""
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
ctx = _mock_task_context()
result = await plugin.execute({"category": "backups"}, context=ctx)
assert result is None
ctx.logger.with_source.assert_called()
class TestStoragePluginGetStorageRoot:
"""Verify get_storage_root path resolution."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_absolute_path(self, mock_get_cm):
"""Absolute storage root returns directly."""
mock_get_cm.return_value = _mock_config_manager(storage_root="/tmp/test_storage")
plugin = StoragePlugin()
root = plugin.get_storage_root()
assert root == Path("/tmp/test_storage")
@patch("src.plugins.storage.plugin.get_config_manager")
def test_relative_path(self, mock_get_cm):
"""Relative storage root resolves against project root."""
mock_get_cm.return_value = _mock_config_manager(storage_root="relative/storage")
plugin = StoragePlugin()
root = plugin.get_storage_root()
# Should resolve relative to project root (parents[3] of plugin.py)
assert root.is_absolute()
assert "relative/storage" in str(root)
class TestStoragePluginResolvePath:
"""Verify resolve_path pattern substitution."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_resolve_with_variables(self, mock_get_cm):
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
result = plugin.resolve_path("backups/{name}_{timestamp}", {"name": "test"})
# {timestamp} is auto-filled, {name} comes from variables
assert result.startswith("backups/test_")
# Verify timestamp format (YYYYMMDDTHHMMSS)
timestamp_part = result.split("_", 1)[1]
assert len(timestamp_part) == 15 # YYYYMMDDTHHMMSS
@patch("src.plugins.storage.plugin.get_config_manager")
def test_resolve_clean_double_slashes(self, mock_get_cm):
"""Double slashes are normalized."""
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
result = plugin.resolve_path("backups//{name}", {"name": "test"})
assert "//" not in result
assert result == "backups/test"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_resolve_missing_variable(self, mock_get_cm):
"""Missing variable falls back to stripped literal."""
mock_get_cm.return_value = _mock_config_manager()
plugin = StoragePlugin()
result = plugin.resolve_path("backups/{name}_{missing}", {"name": "test"})
# Fallback: strip braces from the pattern
assert "{" not in result
assert "}" not in result
class TestStoragePluginValidatePath:
"""Verify validate_path prevents traversal and returns valid paths."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_valid_path(self, mock_get_cm):
"""Path within storage root is accepted."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
# ensure_directories creates category subdirs — don't rmdir parent
plugin = StoragePlugin()
valid = Path(tmpdir) / "test_file.txt"
valid.touch()
result = plugin.validate_path(valid)
assert result == valid.resolve()
@patch("src.plugins.storage.plugin.get_config_manager")
def test_path_traversal_raises(self, mock_get_cm):
"""Path outside storage root raises ValueError."""
mock_get_cm.return_value = _mock_config_manager(storage_root="/tmp/test_storage_traversal")
plugin = StoragePlugin()
outside = Path("/etc/passwd")
with pytest.raises(ValueError, match="Access denied"):
plugin.validate_path(outside)
class TestStoragePluginEnsureDirectories:
"""Verify ensure_directories creates category subfolders."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_ensure_directories_creates_folders(self, mock_get_cm):
"""Category directories are created under storage root."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
plugin = StoragePlugin()
# ensure_directories called in __init__ already
for cat in FileCategory:
assert (Path(tmpdir) / cat.value).exists()
class TestStoragePluginListFiles:
"""Verify list_files in various modes."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_root_view(self, mock_get_cm):
"""Root view (no category) returns category directories."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
# Create category directories
for cat in FileCategory:
(Path(tmpdir) / cat.value).mkdir(parents=True, exist_ok=True)
plugin = StoragePlugin()
files = plugin.list_files()
assert len(files) == len(FileCategory)
names = {f.name for f in files}
for cat in FileCategory:
assert cat.value in names
# All returned as directories
for f in files:
assert f.mime_type == "directory"
assert f.size == 0
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_with_category(self, mock_get_cm):
"""Filter by category returns files in that category."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / "file1.txt").write_text("hello")
(backup_dir / "file2.txt").write_text("world")
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP)
assert len(files) == 2
assert files[0].name == "file1.txt"
assert files[1].name == "file2.txt"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_recursive(self, mock_get_cm):
"""Recursive mode scans subdirectories."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
nested = backup_dir / "sub" / "dir"
nested.mkdir(parents=True, exist_ok=True)
(nested / "deep.txt").write_text("deep")
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP, recursive=True)
assert len(files) == 1
assert files[0].name == "deep.txt"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_missing_category_dir(self, mock_get_cm):
"""Missing category directory returns empty list."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
# Don't create any directories
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP)
assert files == []
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_skip_logs(self, mock_get_cm):
"""Files/dirs containing 'Logs' are skipped."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / "good.txt").write_text("good")
(backup_dir / "LogsFile.txt").write_text("logs")
log_dir = backup_dir / "Logs"
log_dir.mkdir()
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP)
# Only good.txt should appear
names = [f.name for f in files]
assert "good.txt" in names
assert "LogsFile.txt" not in names
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_skip_logs_recursive(self, mock_get_cm):
"""Recursive scan skips files with 'Logs' in their filename (L284)."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
nested = backup_dir / "daily"
nested.mkdir(parents=True, exist_ok=True)
(nested / "report.txt").write_text("clean")
(nested / "TransactionLogs.csv").write_text("log data")
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP, recursive=True)
assert len(files) == 1
assert files[0].name == "report.txt"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_recursive_missing_dir(self, mock_get_cm):
"""Recursive mode with missing category dir continues silently (L274)."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
# Patch ensure_directories so category dirs are NOT created
with patch.object(StoragePlugin, "ensure_directories"):
plugin = StoragePlugin()
files = plugin.list_files(category=FileCategory.BACKUP, recursive=True)
assert files == []
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_root_view_missing_categories(self, mock_get_cm):
"""Root view listing skips missing category dirs via continue (L249)."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
# Patch ensure_directories so only ONE category exists on disk
with patch.object(StoragePlugin, "ensure_directories"):
plugin = StoragePlugin()
# Manually create only one category dir
(Path(tmpdir) / "backups").mkdir()
files = plugin.list_files()
# Only "backups" should appear; "repositories" is missing → hits L249 continue
assert len(files) == 1
assert files[0].name == "backups"
@patch("src.plugins.storage.plugin.get_config_manager")
def test_list_files_with_subpath(self, mock_get_cm):
"""Subpath within category narrows listing."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
nested = Path(tmpdir) / "backups" / "monthly" / "week1"
nested.mkdir(parents=True, exist_ok=True)
(nested / "report.txt").write_text("report")
plugin = StoragePlugin()
files = plugin.list_files(
category=FileCategory.BACKUP,
subpath="monthly/week1",
)
assert len(files) == 1
assert files[0].name == "report.txt"
class TestStoragePluginSaveFile:
"""Verify save_file writes uploaded content to disk."""
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_save_file_without_subpath(self, mock_get_cm):
"""File saved directly under category directory."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
upload_file = MagicMock()
upload_file.filename = "test_doc.pdf"
upload_file.content_type = "application/pdf"
upload_file.read = AsyncMock(return_value=b"test document")
plugin = StoragePlugin()
result = await plugin.save_file(
file=upload_file,
category=FileCategory.BACKUP,
)
assert result.name == "test_doc.pdf"
assert result.category == FileCategory.BACKUP
assert result.mime_type == "application/pdf"
assert result.path.startswith("backups/")
assert isinstance(result, StoredFile)
assert (Path(tmpdir) / result.path).read_bytes() == b"test document"
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_save_file_with_subpath(self, mock_get_cm):
"""File saved under category/subpath."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
upload_file = MagicMock()
upload_file.filename = "report.xlsx"
upload_file.content_type = (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
upload_file.read = AsyncMock(return_value=b"report contents")
plugin = StoragePlugin()
result = await plugin.save_file(
file=upload_file,
category=FileCategory.REPOSITORY,
subpath="archived/2024",
)
assert result.name == "report.xlsx"
assert result.category == FileCategory.REPOSITORY
assert "archived/2024" in result.path
assert (Path(tmpdir) / result.path).read_bytes() == b"report contents"
class TestStoragePluginDeleteFile:
"""Verify delete_file removes files and directories."""
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_delete_file_success(self, mock_get_cm):
"""Existing file is deleted."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / "to_delete.txt"
target.write_text("bye")
plugin = StoragePlugin()
await plugin.delete_file(FileCategory.BACKUP, "backups/to_delete.txt")
assert not target.exists()
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_delete_directory_success(self, mock_get_cm):
"""Existing directory is removed via shutil.rmtree."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
target_dir = backup_dir / "old_backup"
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / "inner.txt").write_text("inner")
plugin = StoragePlugin()
await plugin.delete_file(FileCategory.BACKUP, "backups/old_backup")
assert not target_dir.exists()
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_delete_file_not_found(self, mock_get_cm):
"""Non-existent file raises FileNotFoundError."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
plugin = StoragePlugin()
with pytest.raises(FileNotFoundError, match="not found"):
await plugin.delete_file(FileCategory.BACKUP, "backups/ghost.txt")
@patch("src.plugins.storage.plugin.get_config_manager")
@pytest.mark.asyncio
async def test_delete_file_category_mismatch(self, mock_get_cm):
"""Path not starting with category value raises ValueError."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / "file.txt"
target.write_text("data")
plugin = StoragePlugin()
with pytest.raises(ValueError, match="does not belong to category"):
await plugin.delete_file(FileCategory.BACKUP, "repositories/file.txt")
class TestStoragePluginGetFilePath:
"""Verify get_file_path returns valid paths."""
@patch("src.plugins.storage.plugin.get_config_manager")
def test_get_file_path_success(self, mock_get_cm):
"""Existing file returns resolved path."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / "download.txt"
target.write_text("content")
plugin = StoragePlugin()
result = plugin.get_file_path(FileCategory.BACKUP, "backups/download.txt")
assert result == target.resolve()
@patch("src.plugins.storage.plugin.get_config_manager")
def test_get_file_path_directory_raises(self, mock_get_cm):
"""Directory path raises FileNotFoundError (not a file)."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
plugin = StoragePlugin()
with pytest.raises(FileNotFoundError, match="not found"):
plugin.get_file_path(FileCategory.BACKUP, "backups")
@patch("src.plugins.storage.plugin.get_config_manager")
def test_get_file_path_not_found(self, mock_get_cm):
"""Non-existent file raises FileNotFoundError."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
plugin = StoragePlugin()
with pytest.raises(FileNotFoundError, match="not found"):
plugin.get_file_path(FileCategory.BACKUP, "backups/missing.txt")
@patch("src.plugins.storage.plugin.get_config_manager")
def test_get_file_path_category_mismatch(self, mock_get_cm):
"""Path not starting with category value raises ValueError."""
with tempfile.TemporaryDirectory() as tmpdir:
mock_get_cm.return_value = _mock_config_manager(storage_root=tmpdir)
backup_dir = Path(tmpdir) / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
plugin = StoragePlugin()
with pytest.raises(ValueError, match="does not belong to category"):
plugin.get_file_path(FileCategory.BACKUP, "repositories/download.txt")
# #endregion Test.StoragePlugin