diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2f029..fc0071c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Fixed -- **Profile copy rename not reflected in left column until modal reopen** — saving a renamed profile via the full editor (`_pmgmtSaveFullEdit`) called `loadProfiles()` to refresh `S._profiles` but never called `_renderProfileMgmt()`, so the left-column list was not repainted. The new name only appeared after closing and reopening the modal. Fixed by calling `_renderProfileMgmt()` immediately after `loadProfiles()` and re-applying the `.active` highlight to the correct row. +- **Profile copy rename not reflected in left column until modal reopen** — saving a renamed profile via the full editor (`_pmgmtSaveFullEdit`) called `loadProfiles()` to refresh `S._profiles` but never called `_renderProfileMgmt()`, so the left-column list was not repainted. The new name only appeared after closing and reopening the modal. Fixed by calling `_renderProfileMgmt()` immediately after `loadProfiles()` and re-applying the `.active` highlight to the correct row. 10 new route integration tests added for all profile API endpoints; total test count: 182. --- diff --git a/CLAUDE.md b/CLAUDE.md index a72c36c..d4ba3eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,9 @@ python -m pytest tests/ -q ## Tests -172 tests in `tests/`. No integration tests for live M365/Google connections. +182 tests in `tests/`. No integration tests for live M365/Google connections. -**`tests/test_route_integration.py`** — 44 Flask test-client tests covering security-sensitive paths: viewer token CRUD and scope validation, `GET /api/db/flagged` role/user scope enforcement, bulk disposition isolation, viewer PIN (set/verify/rate-limit/change/clear), interface PIN gate (multi-step flows require `session["interface_ok"] = True` after PIN set — the `before_request` hook blocks the same endpoint once a PIN exists), scan lock release on `run_scan()` exception, `GET /api/db/sessions` shape and ordering. Uses a tmp-path `ScanDB` monkeypatched into `routes.database._get_db` — tests never touch the real database. Interface PIN tests manipulate the real `config.json` via `setup_method`/`teardown_method` calling `clear_interface_pin()`. +**`tests/test_route_integration.py`** — 54 Flask test-client tests covering security-sensitive paths: viewer token CRUD and scope validation, `GET /api/db/flagged` role/user scope enforcement, bulk disposition isolation, viewer PIN (set/verify/rate-limit/change/clear), interface PIN gate (multi-step flows require `session["interface_ok"] = True` after PIN set — the `before_request` hook blocks the same endpoint once a PIN exists), scan lock release on `run_scan()` exception, `GET /api/db/sessions` shape and ordering, profile routes CRUD and rename (including the rename-after-copy regression). Uses a tmp-path `ScanDB` monkeypatched into `routes.database._get_db` — tests never touch the real database. Interface PIN tests manipulate the real `config.json` via `setup_method`/`teardown_method` calling `clear_interface_pin()`. **Local-file scan fixtures** — `tests/fixtures/local_files/` holds 13 documents for manual/UI-level testing of the file scanner. 10 should be flagged; 3 are true negatives. All CPR numbers verified against `is_valid_cpr`. `generate_fixtures.py` (requires `python-docx` + `openpyxl`, already in venv) regenerates the binary `.docx`/`.xlsx` files. diff --git a/GDPRScanner.code-workspace b/GDPRScanner.code-workspace new file mode 100644 index 0000000..40fe023 --- /dev/null +++ b/GDPRScanner.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 2868a02..4d14dba 100644 --- a/README.md +++ b/README.md @@ -601,7 +601,7 @@ pip install pytest pytest tests/ ``` -**172 tests across 5 modules — all expected to pass.** +**182 tests across 5 modules — all expected to pass.** | Module | Tests | Covers | |---|---|---| @@ -609,7 +609,7 @@ pytest tests/ | `tests/test_app_config.py` | 34 | i18n loading, Article 9 keyword detection, config round-trip, admin PIN, profiles CRUD, Fernet encryption | | `tests/test_checkpoint.py` | 18 | Checkpoint key stability, save/load/clear, wrong-key isolation, delta token round-trip | | `tests/test_db.py` | 24 | Scan lifecycle, CPR hash-only storage, data subject lookup, dispositions, export/import cycle | -| `tests/test_route_integration.py` | 44 | Viewer token CRUD, role/user scope enforcement, bulk disposition isolation, viewer PIN, interface PIN gate, scan lock release on failure, session history ordering | +| `tests/test_route_integration.py` | 54 | Viewer token CRUD, role/user scope enforcement, bulk disposition isolation, viewer PIN, interface PIN gate, scan lock release on failure, session history ordering, profile routes CRUD and rename | Each unit-test module (`cpr_detector.py`, `app_config.py`, `checkpoint.py`, `gdpr_db.py`) is importable in isolation without Flask or MSAL — tests run without any cloud credentials or a running server. diff --git a/tests/test_route_integration.py b/tests/test_route_integration.py index 360d4d5..48645d8 100644 --- a/tests/test_route_integration.py +++ b/tests/test_route_integration.py @@ -9,6 +9,7 @@ Covers: - Interface PIN set / gate / clear - Scan lock always released (even when run_scan raises) - GET /api/db/sessions basic shape + - Profile routes CRUD and rename """ from __future__ import annotations import time @@ -522,3 +523,98 @@ class TestDbSessions: assert len(sessions) == 2 # Newest session (highest ref_scan_id) must be first assert sessions[0]["ref_scan_id"] > sessions[1]["ref_scan_id"] + + +# --------------------------------------------------------------------------- +# Profile routes +# --------------------------------------------------------------------------- + +class TestProfileRoutes: + """ + Tests for GET /api/profiles, POST /api/profiles/save, + GET /api/profiles/get, and POST /api/profiles/delete. + + Each test monkeypatches the profile storage path to a tmp directory so + tests are fully isolated from the real ~/.gdprscanner/settings.json. + """ + + @pytest.fixture(autouse=True) + def _isolate(self, tmp_path, monkeypatch): + import app_config + monkeypatch.setattr(app_config, "_SETTINGS_PATH", tmp_path / "settings.json") + + def test_list_returns_empty_list_initially(self, client): + r = client.get("/api/profiles") + assert r.status_code == 200 + assert r.get_json()["profiles"] == [] + + def test_save_missing_name_returns_400(self, client): + r = client.post("/api/profiles/save", json={"sources": ["email"]}) + assert r.status_code == 400 + assert "error" in r.get_json() + + def test_save_creates_profile_and_returns_it(self, client): + r = client.post("/api/profiles/save", json={ + "id": "", "name": "Alpha", "sources": ["email"], "options": {} + }) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "saved" + assert data["profile"]["name"] == "Alpha" + assert data["profile"]["id"] # server assigned a non-empty id + + def test_saved_profile_appears_in_list(self, client): + client.post("/api/profiles/save", json={"name": "Beta", "sources": [], "options": {}}) + profiles = client.get("/api/profiles").get_json()["profiles"] + assert any(p["name"] == "Beta" for p in profiles) + + def test_rename_updates_name_in_list(self, client): + """Regression: _pmgmtSaveFullEdit renames the copy — the API must + persist the new name so loadProfiles() returns fresh data for the + left-column re-render.""" + r = client.post("/api/profiles/save", json={ + "id": "", "name": "LOCAL-TEST (copy)", "sources": [], "options": {} + }) + profile_id = r.get_json()["profile"]["id"] + + # Simulate the user renaming the copy in the editor and clicking Save + r2 = client.post("/api/profiles/save", json={ + "id": profile_id, "name": "LOCAL-TEST-2", "sources": [], "options": {} + }) + assert r2.status_code == 200 + assert r2.get_json()["profile"]["name"] == "LOCAL-TEST-2" + + profiles = client.get("/api/profiles").get_json()["profiles"] + names = [p["name"] for p in profiles] + assert "LOCAL-TEST-2" in names + assert "LOCAL-TEST (copy)" not in names + + def test_get_by_id(self, client): + r = client.post("/api/profiles/save", json={ + "id": "fixed-id-1", "name": "Gamma", "sources": [], "options": {} + }) + profile_id = r.get_json()["profile"]["id"] + r2 = client.get(f"/api/profiles/get?id={profile_id}") + assert r2.status_code == 200 + assert r2.get_json()["profile"]["name"] == "Gamma" + + def test_get_nonexistent_returns_404(self, client): + r = client.get("/api/profiles/get?id=does-not-exist") + assert r.status_code == 404 + + def test_delete_removes_profile(self, client): + client.post("/api/profiles/save", json={"name": "ToDelete", "sources": [], "options": {}}) + r = client.post("/api/profiles/delete", json={"name": "ToDelete"}) + assert r.status_code == 200 + assert r.get_json()["status"] == "deleted" + profiles = client.get("/api/profiles").get_json()["profiles"] + assert not any(p["name"] == "ToDelete" for p in profiles) + + def test_delete_nonexistent_returns_not_found(self, client): + r = client.post("/api/profiles/delete", json={"name": "Ghost"}) + assert r.status_code == 200 + assert r.get_json()["status"] == "not_found" + + def test_delete_missing_key_returns_400(self, client): + r = client.post("/api/profiles/delete", json={}) + assert r.status_code == 400