diff --git a/src/domain/lifecycle/helpdesk.ts b/src/domain/lifecycle/helpdesk.ts new file mode 100644 index 0000000..c3e00b6 --- /dev/null +++ b/src/domain/lifecycle/helpdesk.ts @@ -0,0 +1,355 @@ +import type { IsoDate } from "../dates.js"; +import { addressKey } from "./address.js"; +import { hasAccess } from "./groups.js"; +import { + rhrEmailProblem, + type DirectoryAccount, + type RhrEmailProblem, +} from "./onboarding.js"; +import type { AdminRole, SheetPerson } from "./sheet.js"; + +/** + * Step 8 of the lifecycle sync (capability G): who should be able to reset + * passwords, and who else holds an admin role. Pure — the sheet, Google's + * accounts and grp-helpdesk's members in, a plan out — so every rule is a + * test with plain objects (docs/lifecycle-sync.md, step 8). + * + * Help Desk Admin is carried by a group: a Super Admin assigned the role to + * the security group grp-helpdesk once, and whoever is in it holds the role. + * So hawk-mod never reads or changes an admin role. It compares the group + * with the sheet, and **every change to the group is a click** — by an RA or + * a Super Admin, checked at the click — because joining this group is + * joining the people who can sign in as any mentor. + * + * The group holds Active Mentors with a Help Desk Admin row on + * `Mentor_Admin_Roles` and CORI current (`hasAccess`, the mentor groups' + * gate), by RHR Email. Anyone else in it is removed by Apply — a row + * deleted, a CORI lapse, an account the sheet does not account for — except + * someone **leaving the team**, who is held: their own leaving alert's + * Remove from groups already reaches every group in the Workspace, and two + * buttons removing one person would be one too many. + * + * Separately, `planAdmins` lists every account Google flags as an admin that + * the group does not explain: the Super Admins, and any delegated admin a + * Super Admin gave a role to directly. + */ + +export const HELPDESK_ROLE: AdminRole = "Help Desk Admin"; + +/** + * grp-helpdesk by its permanent Directory ID, like `GOOGLE_GROUP_IDS`: never + * by name or address. The name is what the ID must lead to; an ID that leads + * to a group with another name is the wrong group, and nothing is applied to + * it. An empty ID is a group not set up yet. + */ +export const HELPDESK_GROUP = { name: "grp-helpdesk", id: "" } as const; + +/** An address in grp-helpdesk, and whose it is on the sheet, if anyone's. */ +export type HelpdeskMember = { address: string; personId: string | null }; + +/** Why Apply removes someone from grp-helpdesk. */ +export type HelpdeskRemoveReason = + /** An Active Mentor whose Help Desk Admin row is gone. */ + | "no_row" + /** An Active Mentor whose CORI is not current. */ + | "no_access" + /** An address that is no sheet person's RHR Email. */ + | "not_on_sheet"; + +/** Why someone is leaving the team; their own leaving alert removes them. */ +export type HelpdeskLeavingReason = "inactive" | "status_unknown" | "role_gone"; + +/** Why someone the sheet gives the role cannot be added yet. */ +export type HelpdeskWait = + "no_rhr_email" | "no_access" | RhrEmailProblem["kind"]; + +export type HelpdeskPlan = { + /** Added by Apply. The address is the sheet's RHR Email. */ + add: (HelpdeskMember & { personId: string })[]; + /** Removed by Apply. The address is the group's own spelling. */ + remove: (HelpdeskMember & { reason: HelpdeskRemoveReason })[]; + /** Kept for their own leaving alert's Remove from groups. */ + held: (HelpdeskMember & { + personId: string; + reason: HelpdeskLeavingReason; + })[]; + /** Given the role on the sheet, but not addable yet; nothing to apply. */ + waiting: { personId: string; why: HelpdeskWait }[]; + /** + * A Help Desk Admin row on someone leaving the team. Harmless while they + * are gone, but if they come back the row brings the role back with them + * (on a click), so it is worth deleting. + */ + staleRows: { personId: string; reason: HelpdeskLeavingReason }[]; +}; + +const lower = (a: string) => a.trim().toLowerCase(); + +/** Why a person is leaving the team, for this group; null if they are not. */ +function leavingTeam(p: SheetPerson): HelpdeskLeavingReason | null { + if (p.status === "inactive") return "inactive"; + if (p.status === "unknown") return "status_unknown"; + if (!p.roles.includes("Mentor")) return "role_gone"; + return null; +} + +/** Whether the sheet puts this person in grp-helpdesk. */ +function entitled(p: SheetPerson, asOf: IsoDate): boolean { + return ( + leavingTeam(p) === null && + p.adminRoles.includes(HELPDESK_ROLE) && + hasAccess(p, asOf) + ); +} + +/** + * Sheet people by every address that reaches them: their RHR Email, and the + * other addresses of the Google account it belongs to — the group stores an + * account's primary address, whichever one was added. + */ +function ownersByAddress( + people: readonly SheetPerson[], + directory: readonly DirectoryAccount[] +): Map { + const accountOf = new Map(); + for (const a of directory) { + for (const x of [a.primaryEmail, ...a.aliases]) accountOf.set(lower(x), a); + } + const owner = new Map(); + for (const p of people) { + const rhr = p.mentor?.rhrEmail; + if (!rhr) continue; + owner.set(addressKey(rhr), p); + const account = accountOf.get(lower(rhr)); + for (const x of account ? [account.primaryEmail, ...account.aliases] : []) { + owner.set(addressKey(x), p); + } + } + return owner; +} + +/** + * What Apply would do to grp-helpdesk. `members` is the group's membership + * as Google lists it; `directory` is every user account, to tell an RHR + * Email that reaches nobody from one Google can add. + */ +export function planHelpdesk(args: { + people: readonly SheetPerson[]; + directory: readonly DirectoryAccount[]; + members: Iterable; + asOf: IsoDate; +}): HelpdeskPlan { + const { people, directory, asOf } = args; + const owner = ownersByAddress(people, directory); + const current = new Map(); + for (const m of args.members) current.set(addressKey(m), lower(m)); + const present = new Set(); + for (const key of current.keys()) { + const p = owner.get(key); + if (p) present.add(p.personId); + } + + const plan: HelpdeskPlan = { + add: [], + remove: [], + held: [], + waiting: [], + staleRows: [], + }; + + for (const p of people) { + if (!p.adminRoles.includes(HELPDESK_ROLE)) continue; + const leaving = leavingTeam(p); + if (leaving) { + plan.staleRows.push({ personId: p.personId, reason: leaving }); + continue; + } + // Someone already in the group is Apply's to remove, below; waiting is + // for those it cannot add. + if (present.has(p.personId)) continue; + if (!hasAccess(p, asOf)) { + plan.waiting.push({ personId: p.personId, why: "no_access" }); + continue; + } + const rhr = p.mentor?.rhrEmail; + if (!rhr) { + plan.waiting.push({ personId: p.personId, why: "no_rhr_email" }); + continue; + } + const problem = rhrEmailProblem(rhr, directory); + if (problem) { + plan.waiting.push({ personId: p.personId, why: problem.kind }); + continue; + } + plan.add.push({ address: lower(rhr), personId: p.personId }); + } + + for (const [key, address] of current) { + const p = owner.get(key); + if (!p) { + plan.remove.push({ address, personId: null, reason: "not_on_sheet" }); + continue; + } + if (entitled(p, asOf)) continue; + const leaving = leavingTeam(p); + if (leaving) { + plan.held.push({ address, personId: p.personId, reason: leaving }); + continue; + } + plan.remove.push({ + address, + personId: p.personId, + reason: p.adminRoles.includes(HELPDESK_ROLE) ? "no_access" : "no_row", + }); + } + + const byAddress = (x: HelpdeskMember, y: HelpdeskMember) => + x.address.localeCompare(y.address); + const byPerson = (x: { personId: string }, y: { personId: string }) => + x.personId.localeCompare(y.personId); + plan.add.sort(byAddress); + plan.remove.sort(byAddress); + plan.held.sort(byAddress); + plan.waiting.sort(byPerson); + plan.staleRows.sort(byPerson); + return plan; +} + +/** grp-helpdesk as Google has it now, looked up by its ID. */ +export type FoundGroup = { id: string; name: string }; + +export type HelpdeskDecision = + | { + kind: "apply"; + groupId: string; + add: HelpdeskPlan["add"]; + remove: HelpdeskPlan["remove"]; + } + | { kind: "held"; why: "wrong_group" | "missing"; message: string } + | { kind: "nothing" }; + +/** + * What Apply may do. The stops are the Google groups' ones: an ID that leads + * to a group with another name is the **wrong group**, and a group with no + * ID, or that Google does not have, is **missing**. There is no "refused" + * plan to override: grp-helpdesk is a handful of people, emptying it is a + * legitimate change, and every change is already a click. + */ +export function decideHelpdesk(args: { + plan: HelpdeskPlan; + found: FoundGroup | null; +}): HelpdeskDecision { + const { plan, found } = args; + if (!found) { + return { + kind: "held", + why: "missing", + message: HELPDESK_GROUP.id + ? `Google has no group ${HELPDESK_GROUP.id}` + : `no ID yet for ${HELPDESK_GROUP.name}`, + }; + } + if (lower(found.name) !== HELPDESK_GROUP.name) { + return { + kind: "held", + why: "wrong_group", + message: `its ID belongs to ${found.name}, not ${HELPDESK_GROUP.name}`, + }; + } + if (!plan.add.length && !plan.remove.length) return { kind: "nothing" }; + return { + kind: "apply", + groupId: found.id, + add: plan.add, + remove: plan.remove, + }; +} + +/** A Google account with its two admin flags kept apart. */ +export type AdminAccount = DirectoryAccount & { + /** `isAdmin`: a Super Admin. */ + superAdmin: boolean; + /** `isDelegatedAdmin`: holds some other admin role, not saying which. */ + delegatedAdmin: boolean; +}; + +export type AdminAccountLine = { + account: string; + suspended: boolean; + /** Whose RHR Email reaches this account, if anyone's. */ + personId: string | null; +}; + +export type AdminReport = { + /** Every Super Admin. Super Admin is not on the sheet, by design. */ + superAdmins: AdminAccountLine[]; + /** + * A delegated admin grp-helpdesk does not explain, other than hawk-mod@ + * itself: a role a Super Admin gave someone directly. Null when the group + * could not be read, because then nothing can be told apart. + */ + unexpected: AdminAccountLine[] | null; + /** + * Members of grp-helpdesk Google does not flag as a delegated admin. + * Expected to be empty once the role is on the group; if it is not, Google + * does not flag a role that comes through a group, and the "unexpected" + * list could not see one either. Null when the group could not be read. + */ + unflagged: string[] | null; +}; + +/** + * Every admin Google reports, against what explains it. A suspended account + * is listed too, marked: it holds its role while suspended, and gets it back + * with the account. + */ +export function planAdmins(args: { + people: readonly SheetPerson[]; + directory: readonly AdminAccount[]; + /** grp-helpdesk's members, or null when it could not be read. */ + members: Iterable | null; + /** The account hawk-mod acts as; its own role is never reported. */ + actor: string; +}): AdminReport { + const owner = ownersByAddress(args.people, args.directory); + const listed = args.members === null ? null : [...args.members].map(lower); + const members = listed && new Set(listed.map(addressKey)); + const keys = (a: AdminAccount) => + [a.primaryEmail, ...a.aliases].map((x) => addressKey(x)); + const line = (a: AdminAccount): AdminAccountLine => ({ + account: lower(a.primaryEmail), + suspended: a.suspended, + personId: + keys(a) + .map((k) => owner.get(k)?.personId) + .find(Boolean) ?? null, + }); + const actor = addressKey(args.actor); + + const superAdmins: AdminAccountLine[] = []; + const unexpected: AdminAccountLine[] = []; + const flagged = new Set(); + for (const a of args.directory) { + if (a.superAdmin || a.delegatedAdmin) { + for (const k of keys(a)) flagged.add(k); + } + if (a.superAdmin) { + superAdmins.push(line(a)); + continue; + } + if (!a.delegatedAdmin) continue; + if (keys(a).includes(actor)) continue; + if (members && keys(a).some((k) => members.has(k))) continue; + unexpected.push(line(a)); + } + + const byAccount = (x: AdminAccountLine, y: AdminAccountLine) => + x.account.localeCompare(y.account); + return { + superAdmins: superAdmins.sort(byAccount), + unexpected: members ? unexpected.sort(byAccount) : null, + unflagged: listed + ? listed.filter((m) => !flagged.has(addressKey(m))).sort() + : null, + }; +} diff --git a/src/domain/lifecycle/sheet.ts b/src/domain/lifecycle/sheet.ts index c591b9b..f9850a6 100644 --- a/src/domain/lifecycle/sheet.ts +++ b/src/domain/lifecycle/sheet.ts @@ -16,9 +16,21 @@ export const SHEET_ROLES = [ ] as const; export type SheetRole = (typeof SHEET_ROLES)[number]; -export const ADMIN_ROLES = ["Groups Admin", "Help Desk Admin"] as const; +/** + * The delegated admin roles `Mentor_Admin_Roles` may name. Help Desk Admin is + * the only one given out, and it comes with membership of grp-helpdesk + * (step 8, decided 2026-10-02). + */ +export const ADMIN_ROLES = ["Help Desk Admin"] as const; export type AdminRole = (typeof ADMIN_ROLES)[number]; +/** + * Roles the tab used to offer. Groups Admin is not given out any more: who + * is in which group is the sheet's job, and hawk-mod edits the groups. A row + * still naming it is a problem that says so, rather than an unknown value. + */ +const RETIRED_ADMIN_ROLES: ReadonlySet = new Set(["Groups Admin"]); + /** * `unknown` is a blank or unrecognised Active/Inactive cell. It is kept * distinct rather than defaulted either way: defaulting to active would put a @@ -419,7 +431,14 @@ export function parseSheet(data: SheetData): ParsedSheet { const person = people.get(id)!; for (const row of rows) { const role = row["Admin Role"] as AdminRole; - if (!ADMIN_ROLES.includes(role)) { + if (RETIRED_ADMIN_ROLES.has(role)) { + problems.add( + "Mentor_Admin_Roles", + row._row, + id, + `${role} is no longer given out; delete the row` + ); + } else if (!ADMIN_ROLES.includes(role)) { problems.add( "Mentor_Admin_Roles", row._row, diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index cb2aec7..4407a46 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -40,6 +40,8 @@ type Spec = { personalEmail?: string; /** Emergency_Contacts rows: [Email, Relationship, Rank]. */ contacts?: [string, string, string][]; + /** Mentor_Admin_Roles rows: each one's Admin Role. */ + adminRoles?: string[]; }; const CLEARED = { @@ -86,6 +88,14 @@ function sheet(...specs: Spec[]): SheetData { }) ); } + for (const role of s.adminRoles ?? []) { + data.Mentor_Admin_Roles.push( + row("Mentor_Admin_Roles", data.Mentor_Admin_Roles.length + 2, { + "Person ID": s.id, + "Admin Role": role, + }) + ); + } for (const [Email, Relationship, Rank] of s.contacts ?? []) { data.Emergency_Contacts.push( row("Emergency_Contacts", data.Emergency_Contacts.length + 2, { @@ -195,6 +205,33 @@ describe("lifecycle sheet parsing", () => { assert.equal(p!.mentor?.screeningExpiry, "2029-03-01"); }); + it("reads Help Desk Admin, and says Groups Admin is no longer given out", () => { + const { people, problems } = parseSheet( + sheet( + { ...mentor("P0010"), adminRoles: ["Help Desk Admin"] }, + { ...mentor("P0011"), adminRoles: ["Groups Admin"] } + ) + ); + assert.deepEqual( + people.map((p) => [p.personId, p.adminRoles]), + [ + ["P0010", ["Help Desk Admin"]], + ["P0011", []], + ] + ); + assert.deepEqual( + problems.map((p) => [p.personId, p.tab, p.row, p.message]), + [ + [ + "P0011", + "Mentor_Admin_Roles", + 3, + "Groups Admin is no longer given out; delete the row", + ], + ] + ); + }); + it("ignores the sample people and says so", () => { const { people, problems } = parseSheet( sheet(mentor("P0001"), student("P0002"), mentor("P0010")) diff --git a/test/lifecycleHelpdesk.test.ts b/test/lifecycleHelpdesk.test.ts new file mode 100644 index 0000000..8e2ddab --- /dev/null +++ b/test/lifecycleHelpdesk.test.ts @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + decideHelpdesk, + HELPDESK_GROUP, + planAdmins, + planHelpdesk, + type AdminAccount, + type HelpdeskPlan, +} from "../src/domain/lifecycle/helpdesk.js"; +import type { DirectoryAccount } from "../src/domain/lifecycle/onboarding.js"; +import type { SheetPerson, SheetRole } from "../src/domain/lifecycle/sheet.js"; + +/** + * Step 8: grp-helpdesk carries Help Desk Admin, so who can reset passwords is + * who is in that group. The sheet decides who should be; every change is + * Apply's, except someone leaving the team, whose own alert removes them. + */ + +const AS_OF = "2026-10-02"; + +function mentor( + personId: string, + extra: { + helpdesk?: boolean; + coriExpiry?: string | null; + rhrEmail?: string | null; + status?: SheetPerson["status"]; + roles?: SheetRole[]; + } = {} +): SheetPerson { + return { + personId, + name: `Mentor ${personId}`, + personalEmail: null, + status: extra.status ?? "active", + roles: extra.roles ?? ["Mentor"], + mentor: { + row: 2, + rhrEmail: + extra.rhrEmail === undefined + ? `${personId.toLowerCase()}@rhr.example` + : extra.rhrEmail, + slackUserId: null, + yptExpiry: "2027-08-01", + screeningExpiry: "2028-06-01", + coriExpiry: + extra.coriExpiry === undefined ? "2028-06-01" : extra.coriExpiry, + consentReleaseExpiry: null, + dataPrivacyExpiry: null, + mentorReadyCompleted: null, + lead: false, + ra: false, + }, + student: null, + adminRoles: extra.helpdesk ? ["Help Desk Admin"] : [], + parentEmails: [], + }; +} + +/** A working Google account for each person's RHR Email. */ +function directoryFor( + people: SheetPerson[], + extra: DirectoryAccount[] = [] +): DirectoryAccount[] { + return [ + ...people.flatMap((p) => + p.mentor?.rhrEmail + ? [{ primaryEmail: p.mentor.rhrEmail, aliases: [], suspended: false }] + : [] + ), + ...extra, + ]; +} + +function plan( + people: SheetPerson[], + members: string[] = [], + directory = directoryFor(people) +): HelpdeskPlan { + return planHelpdesk({ people, directory, members, asOf: AS_OF }); +} + +describe("planHelpdesk", () => { + it("adds an Active Mentor with a Help Desk Admin row and CORI current, by RHR Email", () => { + const p = plan([mentor("P0010", { helpdesk: true }), mentor("P0011")]); + assert.deepEqual(p.add, [ + { address: "p0010@rhr.example", personId: "P0010" }, + ]); + assert.deepEqual(p.remove, []); + }); + + it("changes nothing for someone already in the group", () => { + const p = plan( + [mentor("P0010", { helpdesk: true })], + ["P0010@RHR.example"] + ); + assert.deepEqual(p.add, []); + assert.deepEqual(p.remove, []); + }); + + it("removes an Active Mentor whose row is gone", () => { + const p = plan([mentor("P0010")], ["p0010@rhr.example"]); + assert.deepEqual(p.remove, [ + { address: "p0010@rhr.example", personId: "P0010", reason: "no_row" }, + ]); + }); + + it("does not add a mentor whose CORI is not current, and says why", () => { + const p = plan([mentor("P0010", { helpdesk: true, coriExpiry: null })]); + assert.deepEqual(p.add, []); + assert.deepEqual(p.waiting, [{ personId: "P0010", why: "no_access" }]); + }); + + it("removes a member whose CORI lapsed, and lists them once", () => { + const p = plan( + [mentor("P0010", { helpdesk: true, coriExpiry: "2026-09-01" })], + ["p0010@rhr.example"] + ); + assert.deepEqual(p.remove, [ + { address: "p0010@rhr.example", personId: "P0010", reason: "no_access" }, + ]); + assert.deepEqual(p.waiting, []); + }); + + it("holds someone leaving the team for their own Remove from groups", () => { + const people = [ + mentor("P0010", { helpdesk: true, status: "inactive" }), + mentor("P0011", { helpdesk: true, status: "unknown" }), + mentor("P0012", { helpdesk: true, roles: ["Volunteer"] }), + ]; + const p = plan(people, [ + "p0010@rhr.example", + "p0011@rhr.example", + "p0012@rhr.example", + ]); + assert.deepEqual(p.remove, []); + assert.deepEqual( + p.held.map((h) => [h.personId, h.reason]), + [ + ["P0010", "inactive"], + ["P0011", "status_unknown"], + ["P0012", "role_gone"], + ] + ); + }); + + it("lists a Help Desk row left on someone leaving, in or out of the group", () => { + const p = plan([ + mentor("P0010", { helpdesk: true, status: "inactive" }), + mentor("P0011", { status: "inactive" }), + ]); + assert.deepEqual(p.staleRows, [{ personId: "P0010", reason: "inactive" }]); + assert.deepEqual(p.add, []); + }); + + it("offers someone back only while their row is still there", () => { + // Back from Inactive: removed when they left, Active again. + const kept = plan([mentor("P0010", { helpdesk: true })]); + assert.deepEqual( + kept.add.map((a) => a.personId), + ["P0010"] + ); + const deleted = plan([mentor("P0010")]); + assert.deepEqual(deleted.add, []); + }); + + it("removes an address that is no sheet person's", () => { + const p = plan([mentor("P0010")], ["calendar@rhr.example"]); + assert.deepEqual(p.remove, [ + { + address: "calendar@rhr.example", + personId: null, + reason: "not_on_sheet", + }, + ]); + assert.deepEqual(p.held, []); + }); + + it("waits for an RHR Email that does not reach a working account", () => { + const people = [ + mentor("P0010", { helpdesk: true, rhrEmail: null }), + mentor("P0011", { helpdesk: true }), + mentor("P0012", { helpdesk: true }), + mentor("P0013", { helpdesk: true }), + ]; + const directory: DirectoryAccount[] = [ + { primaryEmail: "p0012@rhr.example", aliases: [], suspended: true }, + { + primaryEmail: "someone@rhr.example", + aliases: ["p0013@rhr.example"], + suspended: false, + }, + ]; + const p = plan(people, [], directory); + assert.deepEqual(p.add, []); + assert.deepEqual(p.waiting, [ + { personId: "P0010", why: "no_rhr_email" }, + { personId: "P0011", why: "not_an_account" }, + { personId: "P0012", why: "suspended" }, + { personId: "P0013", why: "alias" }, + ]); + }); + + it("knows a member by their account's primary address when the RHR Email is an alias", () => { + const people = [mentor("P0013", { helpdesk: true })]; + const directory: DirectoryAccount[] = [ + { + primaryEmail: "someone@rhr.example", + aliases: ["p0013@rhr.example"], + suspended: false, + }, + ]; + const p = plan(people, ["someone@rhr.example"], directory); + assert.deepEqual(p.remove, []); + assert.deepEqual(p.add, []); + assert.deepEqual(p.waiting, []); + }); +}); + +describe("decideHelpdesk", () => { + const toAdd = plan([mentor("P0010", { helpdesk: true })]); + + it("applies nothing to a group with no ID or that Google does not have", () => { + const d = decideHelpdesk({ plan: toAdd, found: null }); + assert.equal(d.kind, "held"); + assert.equal(d.kind === "held" && d.why, "missing"); + }); + + it("applies nothing to an ID that leads to another group", () => { + const d = decideHelpdesk({ + plan: toAdd, + found: { id: "abc", name: "grp-mentors" }, + }); + assert.deepEqual(d, { + kind: "held", + why: "wrong_group", + message: "its ID belongs to grp-mentors, not grp-helpdesk", + }); + }); + + it("applies the plan to the right group", () => { + const d = decideHelpdesk({ + plan: toAdd, + found: { id: "abc", name: "grp-helpdesk" }, + }); + assert.equal(d.kind, "apply"); + assert.equal(d.kind === "apply" && d.groupId, "abc"); + }); + + it("has nothing to do when the group matches", () => { + const d = decideHelpdesk({ + plan: plan([mentor("P0010")]), + found: { id: "abc", name: HELPDESK_GROUP.name }, + }); + assert.deepEqual(d, { kind: "nothing" }); + }); + + it("may empty the group: every change is already a click", () => { + const d = decideHelpdesk({ + plan: plan([mentor("P0010")], ["p0010@rhr.example"]), + found: { id: "abc", name: "grp-helpdesk" }, + }); + assert.equal(d.kind, "apply"); + }); +}); + +describe("planAdmins", () => { + const ACTOR = "hawk-mod@rhr.example"; + + function account( + primaryEmail: string, + flags: Partial> = {}, + suspended = false + ): AdminAccount { + return { + primaryEmail, + aliases: [], + suspended, + superAdmin: flags.superAdmin ?? false, + delegatedAdmin: flags.delegatedAdmin ?? false, + }; + } + + it("lists every Super Admin, suspended ones marked, with whose they are", () => { + const r = planAdmins({ + people: [mentor("P0006")], + directory: [ + account("p0006@rhr.example", { superAdmin: true }), + account("old@rhr.example", { superAdmin: true }, true), + account("p0010@rhr.example"), + ], + members: [], + actor: ACTOR, + }); + assert.deepEqual(r.superAdmins, [ + { account: "old@rhr.example", suspended: true, personId: null }, + { account: "p0006@rhr.example", suspended: false, personId: "P0006" }, + ]); + }); + + it("reports a delegated admin grp-helpdesk does not explain, never hawk-mod@", () => { + const r = planAdmins({ + people: [mentor("P0010"), mentor("P0011")], + directory: [ + account(ACTOR, { delegatedAdmin: true }), + account("p0010@rhr.example", { delegatedAdmin: true }), + account("p0011@rhr.example", { delegatedAdmin: true }), + ], + members: ["p0010@rhr.example"], + actor: ACTOR, + }); + assert.deepEqual(r.unexpected, [ + { account: "p0011@rhr.example", suspended: false, personId: "P0011" }, + ]); + }); + + it("tells nothing apart when grp-helpdesk could not be read", () => { + const r = planAdmins({ + people: [], + directory: [account("p0010@rhr.example", { delegatedAdmin: true })], + members: null, + actor: ACTOR, + }); + assert.equal(r.unexpected, null); + assert.equal(r.unflagged, null); + }); + + it("lists members Google does not flag as an admin", () => { + const r = planAdmins({ + people: [], + directory: [ + account("p0010@rhr.example"), + account("p0011@rhr.example", { delegatedAdmin: true }), + account("p0006@rhr.example", { superAdmin: true }), + ], + members: ["P0010@rhr.example", "p0011@rhr.example", "p0006@rhr.example"], + actor: ACTOR, + }); + assert.deepEqual(r.unflagged, ["p0010@rhr.example"]); + }); +});