test: agregar test suite para filtros de exclusion, atributos y modo replica de Karen's Replicator
This commit is contained in:
@@ -107,6 +107,11 @@ windows-agent\dist\OnEverDriveAgent-Standalone.exe
|
|||||||
python windows-agent/build_exe.py
|
python windows-agent/build_exe.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Código de Registro Único
|
||||||
|
OED-5752-EFED
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔒 Seguridad y Multi-Inquilino
|
## 🔒 Seguridad y Multi-Inquilino
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ async def purge_orphans(
|
|||||||
|
|
||||||
purged_count = 0
|
purged_count = 0
|
||||||
total_freed_bytes = 0
|
total_freed_bytes = 0
|
||||||
from app.storage import storage_provider
|
from app.storage.local import storage_provider
|
||||||
|
|
||||||
for db_file in db_files:
|
for db_file in db_files:
|
||||||
normalized_db_path = db_file.client_relative_path.strip().replace("\\", "/").lower()
|
normalized_db_path = db_file.client_relative_path.strip().replace("\\", "/").lower()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user