From e51f25b5bd4b87545aa0a8fb471b8b6fb99db6e2 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Fri, 14 Aug 2026 22:07:06 -0300 Subject: [PATCH] test: agregar test suite para filtros de exclusion, atributos y modo replica de Karen's Replicator --- README.md | 5 + backend/app/api/jobs.py | 2 +- tests/test_karens_features.py | 224 ++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/test_karens_features.py diff --git a/README.md b/README.md index 5529a0d..bc143cd 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,11 @@ windows-agent\dist\OnEverDriveAgent-Standalone.exe python windows-agent/build_exe.py ``` +```powershell +# Código de Registro Único +OED-5752-EFED +``` + --- ## 🔒 Seguridad y Multi-Inquilino diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py index 0004b8a..5589a3a 100644 --- a/backend/app/api/jobs.py +++ b/backend/app/api/jobs.py @@ -291,7 +291,7 @@ async def purge_orphans( purged_count = 0 total_freed_bytes = 0 - from app.storage import storage_provider + from app.storage.local import storage_provider for db_file in db_files: normalized_db_path = db_file.client_relative_path.strip().replace("\\", "/").lower() diff --git a/tests/test_karens_features.py b/tests/test_karens_features.py new file mode 100644 index 0000000..f134d68 --- /dev/null +++ b/tests/test_karens_features.py @@ -0,0 +1,224 @@ +import os +import pytest +import platform +import ctypes +import tempfile +from pathlib import Path +from datetime import datetime, timezone + +from app.core.database import init_db, AsyncSessionLocal +from app.models.models import Client, BackupJob, BackupFile +from app.api.jobs import purge_orphans +from app.schemas.schemas import PurgeOrphansRequest +from app.core.config import settings +from agent.scanner import DirectoryScanner + +def set_windows_attributes(filepath: Path, hidden: bool = False, system: bool = False, readonly: bool = False): + if platform.system() != "Windows": + return + attrs = 0 + if readonly: + attrs |= 0x01 + if hidden: + attrs |= 0x02 + if system: + attrs |= 0x04 + # Set attributes + ctypes.windll.kernel32.SetFileAttributesW(str(filepath), attrs) + +def test_directory_scanner_filtering(): + """ + Tests DirectoryScanner filtering logic: + - Verifies inclusion patterns (e.g. *.txt). + - Verifies exclude patterns (e.g. *.tmp, log_dir). + - Verifies Windows-specific attributes skipping (Hidden, System, Read-only). + """ + with tempfile.TemporaryDirectory() as tmpdir: + root_path = Path(tmpdir) + + # Create normal files + normal_txt = root_path / "normal.txt" + normal_txt.write_text("Hello World") + + # Create excluded extension file + temp_file = root_path / "temp.tmp" + temp_file.write_text("Temp Data") + + # Create file in excluded folder + log_dir = root_path / "logs" + log_dir.mkdir() + log_file = log_dir / "app.log" + log_file.write_text("Log info") + + # Create hidden/system/readonly files (using ctypes helper on Windows) + hidden_txt = root_path / "hidden.txt" + hidden_txt.write_text("Hidden file content") + set_windows_attributes(hidden_txt, hidden=True) + + system_txt = root_path / "system.txt" + system_txt.write_text("System file content") + set_windows_attributes(system_txt, system=True) + + readonly_txt = root_path / "readonly.txt" + readonly_txt.write_text("Readonly file content") + set_windows_attributes(readonly_txt, readonly=True) + + # Scanner 1: Inclusions (*.txt) and Exclusions (*.tmp, logs) + scanner = DirectoryScanner( + source_path=str(root_path), + file_patterns="*.txt", + exclude_patterns="*.tmp, logs", + skip_hidden=False, + skip_system=False, + skip_readonly=False, + min_stable_seconds=0 + ) + files = scanner.scan() + file_names = {f.name for f in files} + + assert "normal.txt" in file_names + assert "temp.tmp" not in file_names + assert "app.log" not in file_names + + # If running on Windows, verify attributes filtering + if platform.system() == "Windows": + assert "hidden.txt" in file_names + assert "system.txt" in file_names + assert "readonly.txt" in file_names + + # Scanner 2: Skip hidden files + scanner_hidden = DirectoryScanner( + source_path=str(root_path), + file_patterns="*.txt", + exclude_patterns="*.tmp, logs", + skip_hidden=True, + min_stable_seconds=0 + ) + files_hidden = scanner_hidden.scan() + names_hidden = {f.name for f in files_hidden} + assert "normal.txt" in names_hidden + assert "hidden.txt" not in names_hidden + + # Scanner 3: Skip system files + scanner_sys = DirectoryScanner( + source_path=str(root_path), + file_patterns="*.txt", + exclude_patterns="*.tmp, logs", + skip_system=True, + min_stable_seconds=0 + ) + files_sys = scanner_sys.scan() + names_sys = {f.name for f in files_sys} + assert "normal.txt" in names_sys + assert "system.txt" not in names_sys + + # Scanner 4: Skip read-only files + scanner_ro = DirectoryScanner( + source_path=str(root_path), + file_patterns="*.txt", + exclude_patterns="*.tmp, logs", + skip_readonly=True, + min_stable_seconds=0 + ) + files_ro = scanner_ro.scan() + names_ro = {f.name for f in files_ro} + assert "normal.txt" in names_ro + assert "readonly.txt" not in names_ro + +@pytest.mark.asyncio +async def test_replica_mode_purge_orphans(): + """ + Tests the purge orphans logic: + - Enforces replica mode deletes files on server that no longer exist on client. + - Decrements total client storage usage bytes accordingly. + """ + await init_db() + + async with AsyncSessionLocal() as db: + # 1. Setup client and job with sync_deletions enabled + client = Client( + client_code="CLIENT-M-01", + name="Mirroring Test Machine", + status="ONLINE", + storage_used_bytes=3000, + storage_quota_bytes=100000 + ) + db.add(client) + await db.flush() + + job = BackupJob( + job_code="JOB-M-01", + client_id=client.id, + name="Replica Test Job", + source_path="C:\\MirrorSource", + sync_deletions=True + ) + db.add(job) + await db.flush() + + # 2. Add three synthetic backup files to backend DB and storage + files_data = [ + ("file1.txt", "clients/CLIENT-M-01/JOB-M-01/file1.txt", 1000), + ("file2.txt", "clients/CLIENT-M-01/JOB-M-01/file2.txt", 1000), + ("file3.txt", "clients/CLIENT-M-01/JOB-M-01/file3.txt", 1000), + ] + + db_files = [] + for name, rel_path, size in files_data: + # Create physical file in settings.STORAGE_ROOT + full_path = Path(settings.STORAGE_ROOT) / rel_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text("mock file contents") + + bf = BackupFile( + client_id=client.id, + job_id=job.id, + filename=name, + relative_path=rel_path, + client_relative_path=name, + file_size=size, + sha256="test-sha256", + is_active=True, + created_at=datetime.now(timezone.utc) + ) + db.add(bf) + db_files.append(bf) + + await db.commit() + + # Verify initial storage state + assert client.storage_used_bytes == 3000 + + # 3. Simulate client backup run reporting only file1.txt and file3.txt are active + # file2.txt has been deleted by client local environment, so it is an orphan + payload = PurgeOrphansRequest(active_relative_paths=["file1.txt", "file3.txt"]) + + # Invoke purge_orphans API method directly + result = await purge_orphans( + job_id=job.id, + payload=payload, + db=db, + current_client=client + ) + + # 4. Verify results + assert result["purged_count"] == 1 + assert result["freed_bytes"] == 1000 + + # Verify file2.txt DB record is no longer active + await db.refresh(db_files[1]) + assert db_files[1].is_active == False + + # Verify file2.txt physical file was deleted + deleted_file_path = Path(settings.STORAGE_ROOT) / db_files[1].relative_path + assert not deleted_file_path.exists(), "Orphan physical file must be deleted from storage root" + + # Verify client storage usage was decremented by 1000 bytes + assert client.storage_used_bytes == 2000 + + # Verify file1.txt and file3.txt remain untouched and active + await db.refresh(db_files[0]) + await db.refresh(db_files[2]) + assert db_files[0].is_active == True + assert db_files[2].is_active == True + assert (Path(settings.STORAGE_ROOT) / db_files[0].relative_path).exists()