8 Commits
Author SHA1 Message Date
StyxX65andClaude Opus 5.5 5e0ce6996b TODO: how to verify a web-UI update actually restarted the app
The web-UI update restarts via os.execv in the same process, so systemd's
ActiveEnterTimestamp doesn't change. Check for a "[skus] Loaded" startup
line after the release was pushed instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:57:12 +02:00
StyxX65andClaude Opus 5.5 5e60d17d60 Release 1.7.13
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- Fix: result cards (and the Excel report) showed account IDs instead of
  names, with no Elev/Ansat badge, for scheduled scans of editor-saved
  profiles.
- VERSION, CHANGELOG and manual version strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:43:00 +02:00
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
StyxX65andClaude Opus 5.5 c55db48894 Document the post-1.7.12 M365+GWS scan report and update checks
TODO.md: open entry for "M365 not scanning M365+GWS accounts after the
1.7.12 update" — symptom, what was verified, likely half-applied update,
server checks to run after an update, what to collect if it recurs,
rollback option, and two possible hardening steps.

routes/CLAUDE.md: note that half-applied updates look like code bugs and
to verify server state before debugging a post-update regression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:44:55 +02:00
StyxX65andClaude Opus 5.5 04efb462af Release 1.7.12
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- Fix: Google-only accounts no longer reach the M365 scan (Graph 404s);
  an M365 scan with no M365 accounts selected no longer scans everyone.
- VERSION, CHANGELOG and manual version strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:30:01 +02:00
StyxX65andClaude Opus 5.5 b5476228ba 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>
2026-09-28 10:28:05 +02:00
StyxX65andClaude Opus 5.5 b60c4a5c47 Release 1.7.11
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- Fix: selecting a profile ticked (and would scan) every M365-only and
  GWS-only account — regression in 1.7.10.
- VERSION, CHANGELOG and manual version strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:21:14 +02:00
StyxX65andClaude Opus 5.5 37c9cbfd2c Fix selecting a profile ticking every M365-only and GWS-only user
_applyPendingProfileUsers() set u.selected = matches(u), where matches()
was `wanted.has(u.id) || (u.googleEmail && ...)`. For users without a
googleEmail (every M365-only and Google-only user) that evaluates to
undefined, not false — and both renderAccountList() and getSelectedUsers()
treat anything other than false as selected. So applying a profile ticked,
and would have scanned, all of those users in addition to the profile's own.

Coerce the googleEmail branch to a boolean.

Reproduced against a copy of real data: a 1-account profile ticked 56
(37 M365-only + 18 GWS-only extras); after the fix it ticks 1, and every
profile ticks only its own stored accounts.

Regression from bbe9c62 (released in 1.7.10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:15:36 +02:00
20 changed files with 454 additions and 62 deletions

No files matched your search

+26
View File
@@ -11,6 +11,32 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
---
## [1.7.13] — 2026-09-28
### Fixed
- **Result cards showed an ID instead of the person's name** — cards from scheduled scans of profiles saved in the profile editor showed the account's Microsoft 365 ID (e.g. `38a9797a-e065-…`) where the name should be, and no Elev/Ansat badge. The profile editor saved accounts as bare IDs, and the scan used the ID as the name. The scan now looks such accounts up for their name and role (using the same Elev/Ansat classification as the account list), the profile editor saves the name and role with each account, and existing cards with an ID as their name now show the name and role from the account list — no rescan needed.
- **Excel report could show IDs instead of names** — for scans started from ID-only account lists, the report's account-name lookup failed as a whole and fell back to the stored values.
---
## [1.7.12] — 2026-09-28
### Fixed
- **Microsoft 365 scan failed with "404 Not Found" for Google-only accounts** — accounts that exist only in Google Workspace were sent to the Microsoft 365 scan (`…/users/google:<email>/mailFolders`), which Graph cannot resolve. This happened when no Microsoft 365 account was ticked (the scanner then took every ticked account, Google-only ones included), and in scheduled scans whose profile contains Google-only accounts. The Microsoft 365 scan now skips Google-only accounts and reports how many it skipped; the Google Workspace scan still covers them.
- **An M365 scan with no M365 accounts selected could scan the whole tenant** — with Google sources ticked and no Microsoft 365 account ticked, the Microsoft 365 part started with an empty account list, which the server treats as "every user". It is now skipped, with a note in the log.
---
## [1.7.11] — 2026-09-28
### Fixed
- **Selecting a profile ticked accounts that aren't in it** — a regression in 1.7.10: applying a profile also ticked every Microsoft 365-only and every Google Workspace-only account in the sidebar, on top of the profile's own accounts. Only accounts present in both M365 and Google Workspace followed the profile correctly. Because a scan uses the ticked accounts, a scan started after selecting a profile in 1.7.10 could cover more accounts than the profile lists. Profiles now tick exactly their own accounts.
---
## [1.7.10] — 2026-09-28
### Changed
+36
View File
@@ -45,6 +45,42 @@ Six root causes fixed in `scan_engine.py` and `document_scanner.py`:
## Pending
### M365 not scanning M365+GWS accounts after the 1.7.12 update — ⏳ awaiting verification
**Reported 2026-09-28.** After updating the server to 1.7.12, a scan did not cover the Microsoft 365 sources for accounts shown with the unified **M365 + GWS** pill, even though they were ticked. After running the update again the problem no longer appeared. Status: being tested by the maintainer; reopen with the details below if it comes back.
**What was verified (against a copy of real profile data, scans intercepted, nothing actually scanned):**
- Applying `STAFF-M365-GWS-FULL` and pressing Scan sends `POST /api/scan/start` with all 33 accounts (31 M365+GWS, 2 M365-only), each with its M365 GUID and no `google:` ids; the Google scan gets the 31 Google emails.
- Server side, the only 1.7.12 change that filters accounts is `scan_engine._resolve_scan_users()`, which drops ids starting with `google:`. M365+GWS accounts are sent with their M365 GUID, so they pass. No other change since 1.7.9 filters accounts.
**Likely cause: a half-applied update** (not proven — no logs from the first update):
- `update_gdpr.sh` only restarts the process if a systemd unit named `gdprscanner` exists; otherwise it prints "restart GDPRScanner manually" and the old Python keeps running against the new static JS.
- A browser tab left open through the update keeps running the old JS modules until reloaded.
- Both update paths auto-stash local edits (`git stash`), silently removing any hotfix made directly on the server.
**Checking a server after an update** (install dir `/opt/gdprscanner`):
```bash
git -C /opt/gdprscanner log --oneline -1 # expect the release commit on origin/main
cat /opt/gdprscanner/VERSION # expect the released version
git -C /opt/gdprscanner status --short # expect no output
git -C /opt/gdprscanner stash list # any entries = local changes the updater set aside
systemctl show gdprscanner -p ActiveEnterTimestamp # must be after the update (CLI/cron updates only)
journalctl -u gdprscanner --since "<update time>" --no-pager | grep "\[skus\] Loaded" # one line per app start
```
The **web-UI update** (Settings → Software update) restarts via `os.execv` in the same process (same PID), so `ActiveEnterTimestamp` does **not** change — use the `[skus] Loaded …` startup line instead: there must be one after the release commit was pushed. (`[ou-roles] Loaded …` lines are logged per account-list load, not per start.)
Then hard-refresh the browser (Cmd+Shift+R) before scanning. Also confirm `git -C /opt/gdprscanner remote -v` points at `https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git` (the repo moved from GitHub).
**If it recurs, collect:** the log from "Starter scanning: …" onward (look for *"Ingen Microsoft 365-konti valgt"*, *"Sprang N konto(er) over, der kun findes i Google Workspace"*, or no Outlook/OneDrive lines at all); how the scan was started (profile / hand-ticked / scheduled job) and which profile; whether the Elev/Ansat filter or search was active; and the output of the checks above.
**Rollback:** 1.7.11 (`v1.7.11`) doesn't have this symptom but still sends Google-only accounts to Graph (404s). Reverting `b547622` on `main` restores that behaviour.
**Possible hardening (not started):**
1. The page notices when the server's `VERSION` has changed since it loaded and asks the user to reload.
2. `update_gdpr.sh` exits non-zero with a clear warning when it cannot restart the service, instead of only logging it.
**Size:** Small · **Priority:** High until verified
---
### #15 — Scan profiles ✅
Named, reusable scan configurations. Full spec in SUGGESTIONS.md §15.
**Size:** Large · **Priority:** High
+1 -1
View File
@@ -1 +1 @@
1.7.10
1.7.13
+2 -2
View File
@@ -1,6 +1,6 @@
# GDPR Scanner — Brugermanual
Version 1.7.10
Version 1.7.13
---
@@ -683,4 +683,4 @@ For en typisk skole- eller kommunescanning er omkostningen ubetydelig — Claude
---
*GDPR Scanner v1.7.10 — teknisk opsætning og konfiguration: se README.md*
*GDPR Scanner v1.7.13 — teknisk opsætning og konfiguration: se README.md*
+2 -2
View File
@@ -1,6 +1,6 @@
# GDPR Scanner — User Manual
Version 1.7.10
Version 1.7.13
---
@@ -683,4 +683,4 @@ For a typical school or municipality scan the cost is negligible — Claude Haik
---
*GDPR Scanner v1.7.10 — for technical setup and configuration see README.md*
*GDPR Scanner v1.7.13 — for technical setup and configuration see README.md*
+5 -1
View File
@@ -744,9 +744,13 @@ def _build_article30_docx() -> tuple[bytes, str]:
# user_ids are stored in the options JSON column
opts_json = json.loads(row["options"] or "{}") if row else {}
for u in opts_json.get("user_ids", []):
# Scheduled scans of editor-saved profiles stored bare id
# strings; .get() on those raised and dropped the whole map.
if not isinstance(u, dict):
continue
uid = u.get("id", "")
name = u.get("displayName", "")
if uid and name:
if uid and name and name != uid:
_acct_map[uid] = name
except Exception:
pass
+2
View File
@@ -260,6 +260,7 @@
"m365_admin_note": "Viser kun din konto. For at liste alle brugere skal en administrator give samtykke til <strong>User.Read.All</strong> i Azure Portal, eller tilføj konti manuelt nedenfor.",
"m365_btn_scan": "Scan",
"m365_btn_stop": "Stop",
"m365_no_m365_accounts": "Ingen Microsoft 365-konti valgt — Microsoft 365-kilderne springes over.",
"m365_btn_stopping": "Stopper…",
"m365_scan_stopping": "Stopper scanning — færdiggør det aktuelle element…",
"m365_scan_stopped": "Scanning stoppet.",
@@ -517,6 +518,7 @@
"m365_filter_special_only": "Art. 9 følsomme data",
"m365_badge_special": "Art.9",
"m365_phase_scanning": "Scanner…",
"m365_skipped_google_accounts": "Sprang {n} konto(er) over, der kun findes i Google Workspace — ikke i Microsoft 365",
"a30_special_cat": "Særlige kategorier (artikel 9)",
"a30_special_cat_note": "Disse elementer indeholder helbredsoplysninger, straffeoplysninger, biometriske data, religiøse, etniske, fagforeningsmæssige, politiske eller seksuelle oplysninger. Et eksplicit retsgrundlag (art. 9, stk. 2) og eventuelt en konsekvensanalyse (art. 35) er påkrævet.",
"a30_col_special": "Art. 9",
+2
View File
@@ -260,6 +260,7 @@
"m365_admin_note": "Es wird nur Ihr Konto angezeigt. Um alle Benutzer aufzulisten, muss ein Administrator <strong>User.Read.All</strong> im Azure Portal genehmigen oder Konten manuell unten hinzufügen.",
"m365_btn_scan": "Scannen",
"m365_btn_stop": "Stop",
"m365_no_m365_accounts": "Keine Microsoft 365-Konten ausgewählt — Microsoft 365-Quellen werden übersprungen.",
"m365_btn_stopping": "Wird gestoppt…",
"m365_scan_stopping": "Scan wird gestoppt — aktuelles Element wird abgeschlossen…",
"m365_scan_stopped": "Scan gestoppt.",
@@ -517,6 +518,7 @@
"m365_filter_special_only": "Art. 9 besondere Kategorien",
"m365_badge_special": "Art.9",
"m365_phase_scanning": "Scanne…",
"m365_skipped_google_accounts": "{n} reine Google Workspace-Konten übersprungen — nicht in Microsoft 365",
"a30_special_cat": "Besondere Kategorien (Artikel 9)",
"a30_special_cat_note": "Diese Elemente enthalten Gesundheits-, Straf-, biometrische, religiöse, ethnische, gewerkschaftliche, politische oder sexuelle Daten. Eine ausdrückliche Rechtsgrundlage (Art. 9 Abs. 2) und ggf. eine DSFA (Art. 35) sind erforderlich.",
"a30_col_special": "Art. 9",
+2
View File
@@ -260,6 +260,7 @@
"m365_admin_note": "Only showing your account. To list all users, an admin must grant <strong>User.Read.All</strong> consent in Azure Portal, or add accounts manually below.",
"m365_btn_scan": "Scan",
"m365_btn_stop": "Stop",
"m365_no_m365_accounts": "No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.",
"m365_btn_stopping": "Stopping…",
"m365_scan_stopping": "Stopping scan — finishing the current item…",
"m365_scan_stopped": "Scan stopped.",
@@ -517,6 +518,7 @@
"m365_filter_special_only": "Art. 9 special category",
"m365_badge_special": "Art.9",
"m365_phase_scanning": "Scanning…",
"m365_skipped_google_accounts": "Skipped {n} Google Workspace-only account(s) — not in Microsoft 365",
"a30_special_cat": "Art. 9 special category items",
"a30_special_cat_note": "These items contain health, criminal, biometric, religious, ethnic, trade union, political, or sexual orientation data. An explicit legal basis (Art. 9(2)) and possibly a DPIA (Art. 35) is required.",
"a30_col_special": "Art. 9",
+25 -1
View File
@@ -226,7 +226,8 @@ class M365Connector:
def lookup_user(self, upn: str) -> dict:
"""Look up one directory user by UPN or email (requires User.Read.All)."""
return self._get(f"/users/{upn}", {"$select": "id,displayName,mail,userPrincipalName"})
return self._get(f"/users/{upn}",
{"$select": "id,displayName,mail,userPrincipalName,assignedLicenses"})
def list_users(self, top: int = 999) -> list:
"""List licensed domain users in the tenant (requires User.Read.All).
@@ -1156,6 +1157,29 @@ class M365Connector:
break
return result
def build_role_sku_map(self, users: list) -> dict:
"""The {skuId: skuPartNumber} map classify_user_role() needs.
Tenant SKUs (get_subscribed_skus) merged with a sample of per-user
licenseDetails — the tenant call may only return the admin's own SKUs.
Shared by GET /api/users and the scan engine so both classify roles
the same way. Never raises; an empty map means ID-only classification.
"""
try:
sku_map = self.get_subscribed_skus()
except Exception:
sku_map = {}
try:
per_user = self.build_sku_map_from_users(users)
if per_user:
added = len(set(per_user) - set(sku_map))
sku_map.update(per_user)
if added:
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
except Exception:
pass
return sku_map
def classify_user_role(self, assigned_licenses: list,
sku_map: dict) -> str:
"""Return 'student', 'staff', or 'other' based on assigned O365 licenses.
+1
View File
@@ -99,6 +99,7 @@ Optional AI-powered NER replacing spaCy. Activated via `config.json` keys `claud
- **`apply_update()` never restarts itself** — callers decide. Tests patch `_schedule_restart`; the auto-update thread calls `_restart_self()` directly.
- **Auto-update thread** — `start_auto_update_thread()` called from `gdpr_scanner.py` `__main__`. Hourly tick, applies at most once per 24 h when `config.json["auto_update"]` is true; skips (and retries next tick) while a scan runs.
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the same logic; keep stash/ff-only/requirements behaviour in sync.
- **Half-applied updates look like code bugs** — `update_gdpr.sh` restarts only if a `gdprscanner` systemd unit exists (else the old Python keeps running under the new static JS), open browser tabs keep old JS modules until reloaded, and both paths auto-stash local edits. Before debugging a post-update regression, verify the server state (HEAD, `VERSION`, clean `git status`, `git stash list`, service start time after the update) — see the "M365 not scanning M365+GWS accounts after the 1.7.12 update" entry in `TODO.md`.
## Viewer mode — routes/viewer.py
+3 -19
View File
@@ -24,25 +24,9 @@ def get_users():
out = []
seen = set()
# Build SKU map for role classification.
# get_subscribed_skus() tries /subscribedSkus → /me/licenseDetails.
# Then always merge per-user licenseDetails on top — this ensures we
# have skuPartNumbers for every distinct SKU in the tenant, not just
# the admin's own license (which is all /me/licenseDetails returns).
try:
sku_map = state.connector.get_subscribed_skus()
except Exception:
sku_map = {}
try:
per_user = state.connector.build_sku_map_from_users(users)
if per_user:
added = len(set(per_user) - set(sku_map))
sku_map.update(per_user)
if added:
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
except Exception:
pass
# SKU map for role classification — shared with the scan engine so the
# account list and scanned cards classify Elev/Ansat identically.
sku_map = state.connector.build_role_sku_map(users)
# Load any manual role overrides set by the admin
_role_overrides = _load_role_overrides()
+65 -6
View File
@@ -42,18 +42,27 @@ except ImportError:
from routes import state as _state
_MANUAL_PREFIX = "manual:"
_GOOGLE_PREFIX = "google:"
def _resolve_manual_users(conn, user_ids: list[dict]) -> list[dict]:
"""Resolve profile accounts saved as "manual:<email>" to their directory id.
def _resolve_scan_users(conn, user_ids: list[dict]) -> list[dict]:
"""Turn the requested accounts into Graph user ids for the M365 scan.
Older profile-editor saves (and saves made while M365 was not connected)
store manual accounts by email only; Graph needs the user id. Accounts that
can't be resolved are reported and skipped — never widened to "all users".
- "manual:<email>" (older profile-editor saves, or saves made while M365
was not connected) is looked up to get the directory id.
- "google:<email>" is a Google-Workspace-only account: it has no M365
mailbox/drive and Graph 404s on it. Profiles and older UI selections can
contain them, so they are dropped here (the Google scan covers them).
Accounts that can't be used are reported and skipped — never widened to
"all users".
"""
resolved, seen = [], set()
skipped_google = []
for u in user_ids:
uid = u.get("id", "")
if uid.startswith(_GOOGLE_PREFIX):
skipped_google.append(uid[len(_GOOGLE_PREFIX):])
continue
if uid.startswith(_MANUAL_PREFIX):
upn = uid[len(_MANUAL_PREFIX):]
try:
@@ -69,9 +78,58 @@ def _resolve_manual_users(conn, user_ids: list[dict]) -> list[dict]:
continue
seen.add(u["id"])
resolved.append(u)
if skipped_google:
logger.info("[run_scan] skipped %d Google-only account(s) in M365 scan", len(skipped_google))
broadcast("scan_phase", {"phase": LANG.get(
"m365_skipped_google_accounts",
"Skipped {n} Google Workspace-only account(s) — not in Microsoft 365").replace(
"{n}", str(len(skipped_google)))})
return resolved
def _enrich_bare_users(conn, user_ids: list[dict]) -> list[dict]:
"""Fill in displayName and userRole for accounts given only by id.
Profiles saved by the profile editor (and scheduled scans of them) pass
plain id strings, which run_scan normalises to displayName=<id>,
userRole="other" — so their cards showed the GUID as the name and no
Elev/Ansat badge. Look those accounts up in the directory once, using the
same role classification as GET /api/users. Accounts that can't be looked
up keep what they had.
"""
bare = {id(u) for u in user_ids
if not u.get("displayName") or u.get("displayName") == u.get("id")}
if not bare:
return user_ids
directory: dict[str, dict] = {}
sku_map: dict = {}
try:
users = conn.list_users()
directory = {u["id"]: u for u in users if u.get("id")}
sku_map = conn.build_role_sku_map(users)
except Exception as e: # e.g. delegated mode without User.Read.All
logger.info("[run_scan] directory list unavailable for name lookup: %s", e)
enriched = []
for u in user_ids:
if id(u) in bare:
uid = u.get("id", "")
d = directory.get(uid)
if d is None:
try:
d = conn.lookup_user(uid) # not in the licensed-member list
except Exception:
d = None
if d:
_email = d.get("mail") or d.get("userPrincipalName", "")
u = {**u, "displayName": _resolve_display_name(d.get("displayName", ""), _email, uid)}
if d.get("assignedLicenses") is not None and u.get("userRole", "other") == "other":
u["userRole"] = conn.classify_user_role(d.get("assignedLicenses", []), sku_map)
enriched.append(u)
return enriched
def _get_scan_abort():
return _state._scan_abort
@@ -670,7 +728,8 @@ def run_scan(options: dict):
me_info.get("displayName", ""),
me_info.get("mail") or me_info.get("userPrincipalName", "me"))}]
else:
user_ids = _resolve_manual_users(conn, user_ids)
user_ids = _resolve_scan_users(conn, user_ids)
user_ids = _enrich_bare_users(conn, user_ids)
sample = user_ids[0] if user_ids else None
logger.info("[run_scan] user_ids: %d entries, type=%s, sample=%s",
len(user_ids), type(user_ids).__name__, sample)
+3 -3
View File
@@ -57,7 +57,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
## Card user/group badge — results.js
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. An `account_name` equal to `account_id` (a GUID stored by older scheduled scans of editor-saved profiles) counts as missing, and a role other than `student`/`staff` is filled from the same `S._allUsers` match. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
## CPR cross-referencing — results.js
@@ -85,13 +85,13 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded.
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded. The editor saves each account as `{id, displayName, userRole}` (plus `email`/`manual` for manual accounts), the same shape as 💾 — not a bare id string: scheduled scans pass profile accounts straight to `run_scan`, and bare ids produced GUID-named cards with no role badge. Server side, `scan_engine._enrich_bare_users()` names any bare ids that remain (older profiles) using `conn.build_role_sku_map()`, the same role classification as `GET /api/users`.
- **Manual accounts must be scannable** — they aren't in the `/api/users` directory list, so:
- `getSelectedUsers()` treats a missing `platform` as `'m365'`. Sidebar `addUserManually()` users historically had no platform and were silently excluded from every scan. Do not go back to a strict `u.platform === 'm365'` check.
- The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: <directory id>, displayName, email, manual: true}`. It falls back to `"manual:<email>"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings.
- `_applyProfile()` → `_addProfileManualUsers()` pushes manual accounts into `S._allUsers` (`manual: true`, `platform: 'm365'`). Legacy `manual:<email>` entries are looked up and the pending ID is rewritten to the resolved one. Without this the account can never be ticked in the sidebar, so it's never scanned.
- `_userListFinal()` keys off `S._m365UsersLoaded`, not `S._allUsers.length`, because manual users can be pushed before the directory loads.
- Server side, `scan_engine._resolve_manual_users()` resolves any remaining `manual:<email>` (e.g. from scheduled scans) via `conn.lookup_user()`. It skips and reports failures and never widens to "all users".
- Server side, `scan_engine._resolve_scan_users()` resolves any remaining `manual:<email>` (e.g. from scheduled scans) via `conn.lookup_user()`, and drops `google:<email>` (Google-only accounts, which Graph 404s on). It skips and reports failures and never widens to "all users".
- **Pending profile users wait for the Google merge** — `_applyPendingProfileUsers()` keeps `window._pendingProfileUserIds` until every ID is matched or `_userListFinal()` (users loaded, and Google either not connected or `S._googleUsersMerged`). `_mergeGoogleUsers()` re-applies in its `finally`. A saved `google:<email>` also matches an M365 user merged to `platform: 'both'` via `u.googleEmail`. Do not clear the pending IDs unconditionally — Google-only users would stay unticked.
- **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`.
- **`copyTokenLink` is async** — called from `onclick` as fire-and-forget. Do not make it synchronous.
+22 -8
View File
@@ -238,7 +238,10 @@ function _applyPendingProfileUsers() {
if (!ids || !ids.length || !S._allUsers.length) return;
const wanted = new Set(ids);
// A saved google:<email> may since have merged onto an M365 user (platform 'both')
const matches = u => wanted.has(u.id) || (u.googleEmail && wanted.has('google:' + u.googleEmail));
// Must return a real boolean: the sidebar and getSelectedUsers() treat any
// selected value other than `false` as ticked, so an `undefined` here (users
// without googleEmail) ticked — and scanned — every M365-only/GWS-only user.
const matches = u => wanted.has(u.id) || !!(u.googleEmail && wanted.has('google:' + u.googleEmail));
S._allUsers.forEach(u => { u.selected = matches(u); });
renderAccountList();
const found = S._allUsers.filter(matches).length;
@@ -434,8 +437,9 @@ function _openEditorForProfile(profile) {
const accountRows = S._allUsers.map(u => {
// Only check if the user was explicitly saved — default to unchecked like the main window
const checked = isSaved(u) ? 'checked' : '';
const manualAttrs = u.manual
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
// data-name lets the save keep the display name with the id (see _pmgmtSaveFullEdit)
const manualAttrs = ` data-name="${_esc(u.displayName || '')}"` + (u.manual
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
@@ -547,8 +551,9 @@ function _unresolvedAccountRows(savedList) {
const label = (typeof u === 'object' && u.displayName) || id.replace(/^(manual|google):/, '');
const badge = isManual ? t('m365_profile_acct_manual', 'Manual')
: t('m365_profile_acct_unresolved', 'Not loaded');
const manualAttrs = _isManualEntry(u) && typeof u === 'object'
? ` data-manual="1" data-name="${_esc(u.displayName || '')}" data-email="${_esc(u.email || '')}"` : '';
const savedName = typeof u === 'object' && u.displayName && u.displayName !== id ? u.displayName : '';
const manualAttrs = (savedName ? ` data-name="${_esc(savedName)}"` : '') + (isManual && typeof u === 'object'
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"${manualAttrs}><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
}).join('');
}
@@ -734,9 +739,18 @@ async function _pmgmtSaveFullEdit() {
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
.filter(cb => cb.dataset.uid)
.map(cb => cb.dataset.manual
? {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '', manual: true}
: cb.dataset.uid);
.map(cb => {
// Save name + role with the id (same shape as 💾 / getSelectedUsers()):
// scheduled scans pass these straight to the scan, and a bare id became
// the card's "name" with no Elev/Ansat badge.
const role = cb.closest('label')?.dataset.role || 'other';
if (cb.dataset.manual) {
return {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '',
userRole: role, manual: true};
}
return cb.dataset.name ? {id: cb.dataset.uid, displayName: cb.dataset.name, userRole: role}
: cb.dataset.uid;
});
// "all" profile opened before users loaded: there were no rows to tick, so keep "all"
if (window._pmgmtSavedAll && !S._allUsers.length && !user_ids.length) user_ids = 'all';
const updated = {
+17 -7
View File
@@ -32,21 +32,31 @@ const SOURCE_BADGES = {
// email), falling back to an email-style account_id. Returns '' when there is
// neither a label nor a role to show.
function _accountPill(f) {
const roleBadge =
f.user_role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
let role = f.user_role;
let label = f.account_name || '';
if (!label && f.account_id) {
// Scheduled scans of editor-saved profiles stored the account id (a GUID) as
// the name, with role "other" — treat that like a missing name.
if (label && f.account_id && label === String(f.account_id)) label = '';
const needName = !label && f.account_id;
const needRole = role !== 'student' && role !== 'staff';
if (f.account_id && (needName || needRole)) {
const aid = String(f.account_id);
const u = (S._allUsers || []).find(function(u) {
return u.id === f.account_id ||
(u.email && u.email.toLowerCase() === aid.toLowerCase());
});
if (u) label = u.displayName || '';
else if (aid.includes('@')) label = aid; // an email is already human-readable
if (u) {
if (needName) label = u.displayName || '';
if (needRole) role = u.userRole;
} else if (needName && aid.includes('@')) {
label = aid; // an email is already human-readable
}
}
const roleBadge =
role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
if (!label && !roleBadge) return '';
const title = label || f.user_role || '';
const title = label || role || '';
return '<span class="account-pill" title="' + esc(title) + '">' + roleBadge + (label ? esc(label) : '') + '</span>';
}
+9 -3
View File
@@ -569,7 +569,13 @@ function startScan(resume) {
document.getElementById('resumeBanner').style.display = 'none';
document.getElementById('logPanel').innerHTML = '<div class="log-line log-live" id="logLive" style="display:none"></div>';
try { sessionStorage.removeItem(_LOG_SESSION_KEY); } catch(e) {}
S._m365ScanRunning = sources.length > 0;
// The M365 scan needs explicit accounts — the server treats an empty list as
// "every user in the tenant". With only Google accounts ticked, skip it.
const runM365 = sources.length > 0 && user_ids.length > 0;
if (sources.length && !runM365) {
log(t('m365_no_m365_accounts', 'No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.'), 'warn');
}
S._m365ScanRunning = runM365;
S._googleScanRunning = googleSources.length > 0;
S._fileScanRunning = fileSources.length > 0;
S._srcPct = { m365: 0, google: 0, file: 0 };
@@ -613,8 +619,8 @@ function startScan(resume) {
}
setTimeout(() => {
// Fire M365 scan if any M365 sources are selected
if (sources.length > 0) {
// Fire M365 scan if any M365 sources and accounts are selected
if (runM365) {
fetch('/api/scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({sources, user_ids, options, resume: !!resume,
+11 -4
View File
@@ -461,10 +461,17 @@ function getSelectedUsers() {
{ id: u.id, displayName: u.displayName, userRole: u.userRole || 'other' },
u.manual ? { manual: true, email: u.email || '' } : {}));
}
// Fallback to DOM if S._allUsers not yet populated
return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
}));
// Once the user list is loaded it is authoritative: no ticked M365 users means
// none. (Falling back to the DOM here collected every ticked checkbox —
// including Google-only "google:<email>" rows — and sent them to the M365
// scan, where Graph 404s on them.)
if (S._allUsers.length) return [];
// Fallback to DOM only while S._allUsers is not yet populated
return Array.from(document.querySelectorAll('.account-check:checked'))
.filter(cb => !String(cb.dataset.id || '').startsWith('google:'))
.map(cb => ({
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
}));
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
+169
View File
@@ -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
+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):