Show account names and roles on cards from scheduled scans
Cards from scheduled scans of editor-saved profiles showed the account's
GUID as its name and no Elev/Ansat badge: the profile editor stored bare
id strings, the scheduler passes them straight to run_scan, and run_scan
normalised them to displayName=<id>, userRole="other". Before 1.7.10 the
scan-start profile overwrite kept repairing profiles to named objects,
which is why this surfaced now.
- run_scan: _enrich_bare_users() looks bare accounts up in the directory
(list_users, falling back to lookup_user) for name and role.
- M365Connector.build_role_sku_map(): the SKU map /api/users built inline,
now shared so cards and the account list classify roles identically.
lookup_user() also returns assignedLicenses.
- Profile editor saves {id, displayName, userRole} (manual accounts keep
email/manual), like the 💾 save.
- _accountPill: an account_name equal to account_id counts as missing;
name and role are filled from the loaded user list, so existing cards
display correctly without a rescan.
- Excel export: the scan's account-name map no longer breaks on bare id
strings (.get() on a str raised and dropped the whole map).
Tests: tests/test_account_names.py (10); the run_scan test fails on the
old engine with "Collecting emails — guid-henrik…".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c55db48894
commit
20156ffc93
8 files changed
+283
-37
No files matched your search
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Result cards must show the account's name and Elev/Ansat role.
|
||||
|
||||
Regression: profiles saved by the profile editor stored bare id strings, and
|
||||
scheduled scans pass those straight to run_scan, which normalised them to
|
||||
displayName=<id>, userRole="other" — so cards showed the GUID as the name and
|
||||
no role badge. run_scan now looks bare accounts up in the directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import scan_engine
|
||||
|
||||
|
||||
class _Conn:
|
||||
"""Directory stub: list_users + role classification + single lookups."""
|
||||
|
||||
def __init__(self, directory=None, extra=None, list_fails=False):
|
||||
self.directory = directory or []
|
||||
self.extra = extra or {} # users only reachable via lookup_user
|
||||
self.list_fails = list_fails
|
||||
self.list_calls = 0
|
||||
self.lookups: list[str] = []
|
||||
|
||||
def list_users(self):
|
||||
self.list_calls += 1
|
||||
if self.list_fails:
|
||||
raise RuntimeError("Authorization_RequestDenied")
|
||||
return list(self.directory)
|
||||
|
||||
def build_role_sku_map(self, users):
|
||||
return {"sku-staff": "STANDARDWOFFPACK_FACULTY", "sku-stud": "STANDARDWOFFPACK_STUDENT"}
|
||||
|
||||
def classify_user_role(self, assigned_licenses, sku_map):
|
||||
names = {sku_map.get(l.get("skuId", ""), "") for l in assigned_licenses}
|
||||
if any("FACULTY" in n for n in names):
|
||||
return "staff"
|
||||
if any("STUDENT" in n for n in names):
|
||||
return "student"
|
||||
return "other"
|
||||
|
||||
def lookup_user(self, uid):
|
||||
self.lookups.append(uid)
|
||||
if uid in self.extra:
|
||||
return self.extra[uid]
|
||||
raise RuntimeError("Request_ResourceNotFound")
|
||||
|
||||
|
||||
DIRECTORY = [
|
||||
{"id": "guid-henrik", "displayName": "Henrik Højmark", "mail": "henrik@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-staff"}]},
|
||||
{"id": "guid-elna", "displayName": "Elna Sofie", "mail": "elna@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-stud"}]},
|
||||
]
|
||||
|
||||
|
||||
def _bare(uid): # what run_scan makes of a bare id string
|
||||
return {"id": uid, "displayName": uid, "userRole": "other"}
|
||||
|
||||
|
||||
class TestEnrichBareUsers:
|
||||
def test_bare_ids_get_name_and_role_from_directory(self):
|
||||
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-henrik"), _bare("guid-elna")])
|
||||
assert out == [
|
||||
{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"},
|
||||
{"id": "guid-elna", "displayName": "Elna Sofie", "userRole": "student"},
|
||||
]
|
||||
|
||||
def test_named_accounts_untouched_and_directory_not_listed(self):
|
||||
conn = _Conn(DIRECTORY)
|
||||
users = [{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"}]
|
||||
assert scan_engine._enrich_bare_users(conn, users) == users
|
||||
assert conn.list_calls == 0 # no directory call when nothing is bare
|
||||
|
||||
def test_existing_role_is_kept(self):
|
||||
# e.g. a role the UI already knew — only a missing ("other") role is filled
|
||||
out = scan_engine._enrich_bare_users(
|
||||
_Conn(DIRECTORY), [{"id": "guid-elna", "displayName": "guid-elna", "userRole": "staff"}])
|
||||
assert out[0]["displayName"] == "Elna Sofie" and out[0]["userRole"] == "staff"
|
||||
|
||||
def test_account_outside_member_list_is_looked_up(self):
|
||||
extra = {"guid-guest": {"id": "guid-guest", "displayName": "Gæst Lærer", "mail": "g@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-staff"}]}}
|
||||
conn = _Conn(DIRECTORY, extra=extra)
|
||||
out = scan_engine._enrich_bare_users(conn, [_bare("guid-guest")])
|
||||
assert out == [{"id": "guid-guest", "displayName": "Gæst Lærer", "userRole": "staff"}]
|
||||
assert conn.lookups == ["guid-guest"]
|
||||
|
||||
def test_directory_list_unavailable_falls_back_to_lookup(self):
|
||||
extra = {"guid-henrik": DIRECTORY[0]}
|
||||
out = scan_engine._enrich_bare_users(_Conn(list_fails=True, extra=extra), [_bare("guid-henrik")])
|
||||
assert out[0]["displayName"] == "Henrik Højmark"
|
||||
|
||||
def test_unknown_account_keeps_what_it_had(self):
|
||||
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-gone")])
|
||||
assert out == [_bare("guid-gone")]
|
||||
|
||||
|
||||
class TestBuildRoleSkuMap:
|
||||
def _conn(self):
|
||||
from m365_connector import M365Connector
|
||||
return M365Connector.__new__(M365Connector) # no auth needed for these calls
|
||||
|
||||
def test_merges_tenant_and_per_user_skus(self, monkeypatch):
|
||||
conn = self._conn()
|
||||
monkeypatch.setattr(conn, "get_subscribed_skus", lambda: {"a": "ADMIN_SKU"})
|
||||
monkeypatch.setattr(conn, "build_sku_map_from_users", lambda users: {"b": "FACULTY_SKU"})
|
||||
assert conn.build_role_sku_map([{"id": "x"}]) == {"a": "ADMIN_SKU", "b": "FACULTY_SKU"}
|
||||
|
||||
def test_never_raises(self, monkeypatch):
|
||||
conn = self._conn()
|
||||
def _boom(*a, **kw): raise RuntimeError("403")
|
||||
monkeypatch.setattr(conn, "get_subscribed_skus", _boom)
|
||||
monkeypatch.setattr(conn, "build_sku_map_from_users", _boom)
|
||||
assert conn.build_role_sku_map([]) == {}
|
||||
|
||||
|
||||
class TestUsersRouteUsesSharedMap:
|
||||
def test_api_users_classifies_with_build_role_sku_map(self, monkeypatch):
|
||||
import gdpr_scanner
|
||||
from routes import state
|
||||
import routes.users as users_mod
|
||||
monkeypatch.setattr(users_mod, "_load_role_overrides", lambda: {})
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
conn.list_users.return_value = DIRECTORY
|
||||
conn.build_role_sku_map.return_value = {"sku-staff": "X_FACULTY"}
|
||||
conn.classify_user_role.side_effect = lambda lic, m: "staff" if m else "other"
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
gdpr_scanner.app.config["TESTING"] = True
|
||||
with gdpr_scanner.app.test_client() as c:
|
||||
data = c.get("/api/users").get_json()
|
||||
conn.build_role_sku_map.assert_called_once_with(DIRECTORY)
|
||||
assert {u["userRole"] for u in data["users"]} == {"staff"}
|
||||
|
||||
|
||||
class TestRunScanNamesBareIds:
|
||||
def test_scheduled_style_bare_ids_are_named_in_the_scan(self, monkeypatch):
|
||||
"""End to end: bare id strings (as a scheduled scan passes them) reach the
|
||||
per-user collection with their real name."""
|
||||
from routes import state
|
||||
import sse
|
||||
sent = []
|
||||
monkeypatch.setattr(scan_engine, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||
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(scan_engine, "_load_role_overrides", lambda: {})
|
||||
monkeypatch.setattr(state, "flagged_items", [])
|
||||
stub = _Conn(DIRECTORY)
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
conn.list_users.side_effect = stub.list_users
|
||||
conn.build_role_sku_map.side_effect = stub.build_role_sku_map
|
||||
conn.classify_user_role.side_effect = stub.classify_user_role
|
||||
conn.list_all_mail_folders_for.return_value = []
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
saved = sse._current_scan_id
|
||||
try:
|
||||
scan_engine.run_scan({"sources": ["email"], "options": {},
|
||||
"user_ids": ["guid-henrik", "guid-elna"]})
|
||||
finally:
|
||||
sse._current_scan_id = saved
|
||||
phases = " | ".join(d.get("phase", "") for e, d in sent if e == "scan_phase")
|
||||
assert "Henrik Højmark" in phases and "Elna Sofie" in phases
|
||||
assert "guid-henrik" not in phases
|
||||
Reference in new issue
Block a user