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>
170 lines
7.5 KiB
Python
170 lines
7.5 KiB
Python
"""
|
|
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
|