Files
GDPRScanner/tests/test_account_names.py
T
StyxX65andClaude Opus 5.5 20156ffc93 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>
2026-09-28 14:40:14 +02:00

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