diff --git a/backend/app/api/common.py b/backend/app/api/common.py index b751fec..5282f3e 100644 --- a/backend/app/api/common.py +++ b/backend/app/api/common.py @@ -56,7 +56,7 @@ def get_exclusion_spec(db_session: Session) -> Optional[pathspec.PathSpec]: for pattern in settings_record.value.splitlines() if pattern.strip() ] - return pathspec.PathSpec.from_lines("gitwildmatch", exclusion_patterns) + return pathspec.PathSpec.from_lines("gitignore", exclusion_patterns) def get_ignored_status( diff --git a/backend/app/api/system/settings.py b/backend/app/api/system/settings.py index 0113cd4..eb00bcb 100644 --- a/backend/app/api/system/settings.py +++ b/backend/app/api/system/settings.py @@ -127,7 +127,7 @@ def test_exclusions( total_files=0, total_size=0, matched_count=0, matched_size=0, sample=[] ) - spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) + spec = pathspec.PathSpec.from_lines("gitignore", patterns) all_files = ( db_session.query(models.FilesystemState) @@ -179,7 +179,7 @@ def download_exclusion_report( if not patterns: raise HTTPException(status_code=400, detail="No patterns provided") - spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) + spec = pathspec.PathSpec.from_lines("gitignore", patterns) all_files = ( db_session.query(models.FilesystemState) diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py new file mode 100644 index 0000000..0ebe740 --- /dev/null +++ b/backend/tests/test_api_settings.py @@ -0,0 +1,196 @@ +from app.db import models + +# ── Settings CRUD ── + + +def test_get_settings_empty(client): + """Tests retrieving settings when none are set.""" + response = client.get("/system/settings") + assert response.status_code == 200 + assert response.json() == {} + + +def test_update_settings(client): + """Tests updating a system setting.""" + response = client.post( + "/system/settings", json={"key": "schedule_scan", "value": "0 2 * * *"} + ) + assert response.status_code == 200 + assert response.json() == {"message": "Setting committed."} + + # Verify retrieval + response = client.get("/system/settings") + assert response.json()["schedule_scan"] == "0 2 * * *" + + +def test_update_settings_triggers_scheduler_reload(client, mocker): + """Tests that updating schedule_scan reloads the scheduler.""" + from app.services.scheduler import scheduler_manager + + reload_spy = mocker.spy(scheduler_manager, "reload") + response = client.post( + "/system/settings", json={"key": "schedule_scan", "value": "0 3 * * *"} + ) + assert response.status_code == 200 + reload_spy.assert_called_once() + + +def test_update_global_exclusions_recomputes_policy(client, db_session, mocker): + """Tests that updating global_exclusions triggers policy recompute.""" + recompute_spy = mocker.patch("app.api.system.settings.recompute_exclusion_policy") + response = client.post( + "/system/settings", + json={"key": "global_exclusions", "value": "*.tmp\n*.log"}, + ) + assert response.status_code == 200 + recompute_spy.assert_called_once() + + +# ── Exclusion Testing ── + + +def test_test_exclusions_empty_patterns(client): + """Tests exclusion test with empty patterns returns zeros.""" + response = client.post( + "/system/settings/test-exclusions", + json={"patterns": "", "limit": 10}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_files"] == 0 + assert data["matched_count"] == 0 + assert data["sample"] == [] + + +def test_test_exclusions_matches_files(client, db_session): + """Tests exclusion patterns against indexed files.""" + db_session.add_all( + [ + models.FilesystemState( + file_path="/data/file.txt", size=100, mtime=1000, is_deleted=False + ), + models.FilesystemState( + file_path="/data/temp.tmp", size=50, mtime=1000, is_deleted=False + ), + models.FilesystemState( + file_path="/data/debug.log", size=200, mtime=1000, is_deleted=False + ), + ] + ) + db_session.commit() + + response = client.post( + "/system/settings/test-exclusions", + json={"patterns": "*.tmp\n*.log", "limit": 10}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_files"] == 3 + assert data["matched_count"] == 2 + assert data["matched_size"] == 250 + assert len(data["sample"]) == 2 + + +def test_test_exclusions_deleted_files_excluded(client, db_session): + """Tests that deleted files are excluded from exclusion testing.""" + db_session.add_all( + [ + models.FilesystemState( + file_path="/data/keep.txt", + size=100, + mtime=1000, + is_deleted=False, + ), + models.FilesystemState( + file_path="/data/old.tmp", + size=50, + mtime=1000, + is_deleted=True, + ), + ] + ) + db_session.commit() + + response = client.post( + "/system/settings/test-exclusions", + json={"patterns": "*.tmp", "limit": 10}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_files"] == 1 + assert data["matched_count"] == 0 + + +# ── Exclusion Report Download ── + + +def test_download_exclusion_report(client, db_session): + """Tests CSV report generation for exclusion matches.""" + db_session.add( + models.FilesystemState( + file_path="/data/target.log", size=100, mtime=1000, is_deleted=False + ) + ) + db_session.commit() + + response = client.post( + "/system/settings/test-exclusions/download", + json={"patterns": "*.log", "limit": 10}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "text/csv; charset=utf-8" + assert "exclusion_report.csv" in response.headers["content-disposition"] + content = response.content.decode("utf-8") + assert "path,size,mtime,sha256_hash" in content + assert "target.log" in content + + +def test_download_exclusion_report_no_patterns(client): + """Tests download with empty patterns returns 400.""" + response = client.post( + "/system/settings/test-exclusions/download", + json={"patterns": "", "limit": 10}, + ) + assert response.status_code == 400 + assert "No patterns provided" in response.json()["detail"] + + +# ── Secrets Keystore (complementing test_api_system.py) ── + + +def test_create_secret(client): + """Tests creating a secret.""" + response = client.post( + "/system/secrets", json={"name": "api-key", "value": "secret123"} + ) + assert response.status_code == 200 + assert "stored" in response.json()["message"] + + response = client.get("/system/secrets") + assert "api-key" in response.json() + + +def test_get_secret_value(client): + """Tests retrieving a secret value.""" + client.post("/system/secrets", json={"name": "key-1", "value": "val-1"}) + + response = client.get("/system/secrets/key-1") + assert response.status_code == 200 + assert response.json()["value"] == "val-1" + + +def test_delete_secret(client): + """Tests deleting a secret.""" + client.post("/system/secrets", json={"name": "to-delete", "value": "x"}) + + response = client.request("DELETE", "/system/secrets", json={"name": "to-delete"}) + assert response.status_code == 200 + + response = client.get("/system/secrets") + assert "to-delete" not in response.json() + + +def test_delete_secret_not_found(client): + """Tests deleting a non-existent secret returns 404.""" + response = client.request("DELETE", "/system/secrets", json={"name": "missing"}) + assert response.status_code == 404 diff --git a/backend/tests/test_api_system.py b/backend/tests/test_api_system.py index 739af8f..51b5b31 100644 --- a/backend/tests/test_api_system.py +++ b/backend/tests/test_api_system.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from app.db import models @@ -560,3 +561,67 @@ def test_test_notification_invalid_url(client): ) assert response.status_code == 500 assert "Failed to dispatch test alert" in response.json()["detail"] + + +# ── Host Directory Listing ── + + +def test_ls_traversal_rejected(client): + """Tests that path traversal attempts are blocked.""" + response = client.get("/system/ls?path=/etc/../secret") + assert response.status_code == 403 + assert "Path traversal not allowed" in response.json()["detail"] + + +def test_ls_nonexistent_path(client): + """Tests listing a non-existent directory returns empty list.""" + response = client.get("/system/ls?path=/nonexistent_path_12345") + assert response.status_code == 200 + assert response.json() == [] + + +# ── System Tree ── + + +def test_system_tree_root(client, db_session): + """Tests system tree at ROOT returns configured source roots.""" + db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]')) + db_session.commit() + + response = client.get("/system/tree") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "/source_data" + assert data[0]["has_children"] is True + + +def test_system_tree_subdirectory(client, db_session): + """Tests system tree browsing a subdirectory.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + db_session.add( + models.SystemSetting(key="source_roots", value=json.dumps([tmpdir])) + ) + db_session.commit() + + # Create a subdirectory + import os + + os.makedirs(os.path.join(tmpdir, "subdir")) + + response = client.get(f"/system/tree?path={tmpdir}") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "subdir" + + +def test_system_tree_outside_roots(client, db_session): + """Tests tree browsing outside roots returns 403.""" + db_session.add(models.SystemSetting(key="source_roots", value='["/source_data"]')) + db_session.commit() + + response = client.get("/system/tree?path=/etc") + assert response.status_code == 403 diff --git a/backend/tests/test_service_scheduler.py b/backend/tests/test_service_scheduler.py new file mode 100644 index 0000000..988f3e5 --- /dev/null +++ b/backend/tests/test_service_scheduler.py @@ -0,0 +1,135 @@ +from app.services.scheduler import SchedulerService +from app.db import models + + +def test_scheduler_start_stop(): + """Tests scheduler lifecycle (start, stop, idempotent).""" + scheduler = SchedulerService() + assert not scheduler.scheduler.running + + scheduler.start() + assert scheduler.scheduler.running + + # Idempotent start + scheduler.start() + assert scheduler.scheduler.running + + scheduler.stop() + assert not scheduler.scheduler.running + + # Idempotent stop + scheduler.stop() + assert not scheduler.scheduler.running + + +def test_scheduler_load_schedules_empty(): + """Tests load_schedules with no cron settings configured.""" + scheduler = SchedulerService() + scheduler.start() + + scheduler.load_schedules() + + # No jobs should be registered + assert scheduler.scheduler.get_job("system_scan") is None + assert scheduler.scheduler.get_job("system_archival") is None + + scheduler.stop() + + +def test_scheduler_load_schedules_with_scan(db_session): + """Tests load_schedules picks up a scan schedule from settings.""" + db_session.add(models.SystemSetting(key="schedule_scan", value="0 2 * * *")) + db_session.commit() + + scheduler = SchedulerService() + scheduler.start() + + scheduler.load_schedules() + + job = scheduler.scheduler.get_job("system_scan") + assert job is not None + assert job.id == "system_scan" + + scheduler.stop() + + +def test_scheduler_add_remove_job(): + """Tests adding and removing scheduled jobs.""" + scheduler = SchedulerService() + scheduler.start() + + def dummy_job(): + pass + + scheduler.add_job("test_job", dummy_job, "0 0 * * *") + assert scheduler.scheduler.get_job("test_job") is not None + + scheduler.remove_job("test_job") + assert scheduler.scheduler.get_job("test_job") is None + + # Idempotent remove + scheduler.remove_job("test_job") + assert scheduler.scheduler.get_job("test_job") is None + + scheduler.stop() + + +def test_scheduler_add_job_empty_cron(): + """Tests that empty/whitespace cron expression removes the job.""" + scheduler = SchedulerService() + scheduler.start() + + def dummy_job(): + pass + + scheduler.add_job("test_job", dummy_job, "0 0 * * *") + assert scheduler.scheduler.get_job("test_job") is not None + + # Empty string should remove + scheduler.add_job("test_job", dummy_job, " ") + assert scheduler.scheduler.get_job("test_job") is None + + scheduler.stop() + + +def test_scheduler_reload(db_session, mocker): + """Tests reload calls load_schedules.""" + db_session.add(models.SystemSetting(key="schedule_scan", value="0 3 * * *")) + db_session.commit() + + scheduler = SchedulerService() + scheduler.start() + + load_spy = mocker.spy(scheduler, "load_schedules") + scheduler.reload() + + load_spy.assert_called_once() + + job = scheduler.scheduler.get_job("system_scan") + assert job is not None + + scheduler.stop() + + +def test_scheduler_run_system_scan_skips_when_running(mocker): + """Tests run_system_scan is skipped when scanner_manager is already running.""" + scheduler = SchedulerService() + + mocker.patch("app.services.scheduler.scanner_manager.is_running", True) + scan_sources_spy = mocker.patch( + "app.services.scheduler.scanner_manager.scan_sources" + ) + + scheduler.run_system_scan() + scan_sources_spy.assert_not_called() + + +def test_scheduler_run_system_archival_no_online_media(db_session, mocker): + """Tests run_system_archival skips when no active media is online.""" + scheduler = SchedulerService() + + # No media in DB + run_backup_spy = mocker.patch("app.services.scheduler.archiver_manager.run_backup") + + scheduler.run_system_archival() + run_backup_spy.assert_not_called()