Fix profiles losing their selected users

Starting an M365 scan wrote the sidebar state back into the active profile
via _save_settings(), replacing its user_ids with the sidebar selection
(minus Google-only users and anything hidden by the role filter). With no
profile active it overwrote profiles[0]. Profiles are loaders, so scan
start no longer writes them; profile_id is audit-only.

The profile editor built its account list from S._allUsers only, so
manually added accounts, users not yet loaded, and users gone from the
directory were not rendered and got dropped on the next save. They now
render as checked "Manual" / "Not loaded" rows, and user_ids "all" is kept
when saved before users load.

Applying a profile cleared the pending user IDs before the un-awaited
Google merge had added google:<email> users, leaving them unticked.
Pending IDs are now kept until all are matched or the user list is final,
and _mergeGoogleUsers() re-applies them. A saved google:<email> also
matches an M365 user merged to platform 'both'.

Adds regression tests for scan start leaving profiles untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Opus 5.5 committed 2026-09-28 09:22:32 +02:00
1 parent b77bf70ff5
commit bbe9c62ff9
10 files changed
+146 -17

No files matched your search

+74
View File
@@ -661,3 +661,77 @@ class TestProfileRoutes:
def test_delete_missing_key_returns_400(self, client):
r = client.post("/api/profiles/delete", json={})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Scan start must not write profiles
# ---------------------------------------------------------------------------
class TestScanStartLeavesProfilesAlone:
"""
Regression: POST /api/scan/start used to call _save_settings() with the
sidebar state, overwriting the active profile's user_ids (dropping
Google-only users) — or profiles[0] when no profile was active.
"""
@pytest.fixture(autouse=True)
def _isolate(self, tmp_path, monkeypatch):
import app_config, scan_engine
monkeypatch.setattr(app_config, "_SETTINGS_PATH", tmp_path / "settings.json")
monkeypatch.setattr(scan_engine, "run_scan", lambda opts: None)
@staticmethod
def _wait_for_lock():
from routes import state
deadline = time.time() + 2.0
while not state._scan_lock.acquire(blocking=False):
assert time.time() < deadline, "scan lock was never released"
time.sleep(0.05)
state._scan_lock.release()
def _save(self, client, name, user_ids):
r = client.post("/api/profiles/save", json={
"name": name, "sources": ["email", "gmail"],
"google_sources": ["gmail"], "user_ids": user_ids, "options": {},
})
return r.get_json()["profile"]
def _get(self, client, pid):
return client.get(f"/api/profiles/get?id={pid}").get_json()["profile"]
def test_active_profile_users_unchanged(self, client, mock_connector):
users = ["m365-a", "google:b@school.dk", "manual:c@school.dk"]
p = self._save(client, "Staff", users)
r = client.post("/api/scan/start", json={
"sources": ["email"],
"user_ids": [{"id": "m365-z", "displayName": "Z"}],
"options": {"older_than_days": 0},
"profile_id": p["id"],
})
assert r.status_code == 200
self._wait_for_lock()
after = self._get(client, p["id"])
assert after["user_ids"] == users
assert after["sources"] == ["email", "gmail"]
def test_no_active_profile_leaves_first_profile_unchanged(self, client, mock_connector):
first = self._save(client, "First", ["m365-a", "m365-b"])
self._save(client, "Second", ["m365-c"])
r = client.post("/api/scan/start", json={
"sources": ["email"],
"user_ids": [{"id": "m365-z", "displayName": "Z"}],
"options": {},
"profile_id": None,
})
assert r.status_code == 200
self._wait_for_lock()
assert self._get(client, first["id"])["user_ids"] == ["m365-a", "m365-b"]
assert len(client.get("/api/profiles").get_json()["profiles"]) == 2
def test_scan_start_creates_no_profile(self, client, mock_connector):
r = client.post("/api/scan/start", json={
"sources": ["email"], "user_ids": [{"id": "m365-z"}], "options": {},
})
assert r.status_code == 200
self._wait_for_lock()
assert client.get("/api/profiles").get_json()["profiles"] == []