From b6a9c6a6da3dbf82db6df02351798592a53807e1 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Fri, 2 Oct 2026 20:35:43 -0400 Subject: [PATCH] Lifecycle step 8 (1/4): grp-helpdesk planner Help Desk Admin is carried by the security group grp-helpdesk, computed from Mentor_Admin_Roles: Active Mentors with a Help Desk Admin row and CORI current, by RHR Email. planHelpdesk diffs that against the group. Apply adds, and removes anyone else (row gone, CORI lapsed, an address the sheet does not have), except someone leaving the team, who is held for their own Remove from groups. It also lists who cannot be added yet and Help Desk rows left on people who left. decideHelpdesk refuses a missing or wrong group; there is no "refused" plan, since every change is a click. planAdmins lists the Super Admins, any delegated admin grp-helpdesk does not explain (never hawk-mod@), and members Google does not flag as admins, which is how the dry run will show whether Google flags a role that comes through a group. Groups Admin is no longer an admin role the sheet may name; a row still naming it is a sheet problem saying so. Pure; nothing reads Google yet, and grp-helpdesk has no ID yet. Co-Authored-By: Claude Opus 5.5 --- src/domain/lifecycle/helpdesk.ts | 355 +++++++++++++++++++++++++++++++ src/domain/lifecycle/sheet.ts | 23 +- test/lifecycle.test.ts | 37 ++++ test/lifecycleHelpdesk.test.ts | 342 +++++++++++++++++++++++++++++ 4 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 src/domain/lifecycle/helpdesk.ts create mode 100644 test/lifecycleHelpdesk.test.ts 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"]); + }); +});