""" 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=, 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