Keep Google-only accounts out of the M365 scan

Graph returned 404 for /users/google:<email>/mailFolders: Google-
Workspace-only accounts (id "google:<email>") reached the M365 scan.

- getSelectedUsers() fell back to "every ticked checkbox in the DOM"
  whenever no M365 account was ticked — including Google-only rows. The
  fallback is now only used before the user list has loaded, and never
  includes google: rows.
- startScan() skips the M365 part (with a log line) when M365 sources are
  ticked but no M365 accounts are: the server treats an empty account
  list as "every user in the tenant".
- run_scan: _resolve_manual_users -> _resolve_scan_users, which now also
  drops google: ids (reported as a phase message). Covers scheduled scans
  and profiles that already contain Google-only accounts. Never widens to
  all users.

Tests: 3 new in tests/test_manual_users.py; the run_scan one fails on the
old engine with exactly the reported Graph call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Opus 5.5 committed 2026-09-28 10:28:05 +02:00
1 parent b60c4a5c47
commit b5476228ba
8 files changed
+99 -19

No files matched your search

+51 -5
View File
@@ -37,7 +37,7 @@ class TestResolveManualUsers:
def test_manual_email_resolved_to_directory_id(self, broadcasts):
conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann Andersen",
"mail": "ann@school.dk"}})
out = scan_engine._resolve_manual_users(conn, [
out = scan_engine._resolve_scan_users(conn, [
{"id": "manual:ann@school.dk", "displayName": "manual:ann@school.dk", "userRole": "other"},
])
assert out == [{"id": "guid-ann", "displayName": "Ann Andersen", "userRole": "other"}]
@@ -48,12 +48,12 @@ class TestResolveManualUsers:
conn = _Conn({})
users = [{"id": "guid-1", "displayName": "One"},
{"id": "guid-2", "displayName": "Two", "manual": True, "email": "two@school.dk"}]
assert scan_engine._resolve_manual_users(conn, users) == users
assert scan_engine._resolve_scan_users(conn, users) == users
assert conn.lookups == []
def test_unknown_manual_account_skipped_and_reported(self, broadcasts):
conn = _Conn({})
out = scan_engine._resolve_manual_users(conn, [
out = scan_engine._resolve_scan_users(conn, [
{"id": "guid-1", "displayName": "One"},
{"id": "manual:ghost@school.dk", "displayName": "ghost"},
])
@@ -63,7 +63,7 @@ class TestResolveManualUsers:
def test_manual_account_already_selected_is_deduplicated(self, broadcasts):
conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann"}})
out = scan_engine._resolve_manual_users(conn, [
out = scan_engine._resolve_scan_users(conn, [
{"id": "guid-ann", "displayName": "Ann"},
{"id": "manual:ann@school.dk", "displayName": "ann"},
])
@@ -73,10 +73,56 @@ class TestResolveManualUsers:
# run_scan resolves inside the non-empty branch, so an empty result must
# stay empty — it must never widen to "scan every tenant user".
conn = _Conn({})
assert scan_engine._resolve_manual_users(
assert scan_engine._resolve_scan_users(
conn, [{"id": "manual:x@school.dk", "displayName": "x"}]) == []
class TestGoogleOnlyAccountsDropped:
"""Regression: google:<email> ids reached the M365 scan and Graph 404'd
("/users/google:anne@school.dk/mailFolders")."""
def test_google_only_accounts_are_dropped_and_reported(self, broadcasts):
conn = _Conn({})
out = scan_engine._resolve_scan_users(conn, [
{"id": "guid-1", "displayName": "One"},
{"id": "google:anne@school.dk", "displayName": "Anne"},
{"id": "google:appleadmin@adm.school.dk", "displayName": "Apple Admin"},
])
assert out == [{"id": "guid-1", "displayName": "One"}]
assert conn.lookups == []
phases = [d["phase"] for e, d in broadcasts if e == "scan_phase"]
assert phases and "2" in phases[0]
def test_only_google_accounts_gives_empty_list_not_all_users(self, broadcasts):
assert scan_engine._resolve_scan_users(_Conn({}), [
{"id": "google:anne@school.dk", "displayName": "Anne"}]) == []
def test_run_scan_never_calls_graph_for_google_ids(self, broadcasts, monkeypatch):
from routes import state
monkeypatch.setattr(scan_engine, "DB_OK", False)
monkeypatch.setattr(scan_engine, "_load_checkpoint", lambda key, **kw: None)
monkeypatch.setattr(scan_engine, "_save_checkpoint", lambda *a, **kw: None)
monkeypatch.setattr(scan_engine, "_clear_checkpoint", lambda **kw: None)
monkeypatch.setattr(state, "flagged_items", [])
conn = MagicMock()
conn.is_app_mode = True
conn.list_all_mail_folders_for.return_value = []
monkeypatch.setattr(state, "connector", conn)
import sse
saved = sse._current_scan_id
try:
scan_engine.run_scan({"sources": ["email"], "options": {}, "user_ids": [
{"id": "google:anne@school.dk", "displayName": "Anne"},
{"id": "guid-1", "displayName": "One"},
]})
finally:
sse._current_scan_id = saved
called_ids = {c.args[0] for c in conn.list_all_mail_folders_for.call_args_list}
assert "guid-1" in called_ids
assert not any(str(i).startswith("google:") for i in called_ids)
conn.list_users.assert_not_called() # never widened to the whole tenant
class TestLookupRoute:
@pytest.fixture()
def client(self):