import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { LeadershipTransfer, Role, User } from "@/payload-types"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { cancelLeadershipTransfer, requestLeadershipTransfer, resolveLeadershipTransfer, } from "@/lib/transfers/leadership"; let payload: Payload; const RUN = `leadxfer-${Date.now().toString(36)}`; const TIMEOUT = 30_000; describe("Leadership-initiated removals (leadership-transfers)", () => { const userIds: number[] = []; const roleIds: number[] = []; const assignmentIds: number[] = []; const transferIds: number[] = []; const users: Record = {}; const assignmentIdsByName: Record = {}; let pendingDoc: LeadershipTransfer | null = null; const makeRole = async (label: string, extra: Partial = {}): Promise => { const role = (await payload.create({ collection: "roles", data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra }, overrideAccess: true, depth: 0, })) as unknown as Role; roleIds.push(role.id); return role; }; const makeUser = async (label: string, extra: Partial = {}): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}`, displayName: label.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], ...extra, }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const makeAssignment = async ( label: string, type: "division" | "squad", leader: number | null, members: number[], ): Promise => { const doc = await payload.create({ collection: "assignments", data: { name: `${RUN} ${label}`, type, leader, members }, overrideAccess: true, depth: 0, }); assignmentIds.push(doc.id); assignmentIdsByName[label] = doc.id; return doc.id; }; const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => { const doc = await payload.findByID({ collection: "assignments", id, depth: 0 }); return { leader: (doc.leader as number | null) ?? null, members: (doc.members as number[]) ?? [], }; }; const notificationsFor = async (userId: number, type?: string) => { const res = await payload.find({ collection: "user-notifications", where: type ? { and: [{ user: { equals: userId } }, { type: { equals: type } }] } : { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; const leadershipEventsFor = async (transferId: number) => { const res = await payload.find({ collection: "game-event-logs", where: { and: [ { targetCollection: { equals: "leadership-transfers" } }, { targetId: { equals: transferId } }, ], }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // The per-user permission cache is keyed by numeric id, which can collide // with users from previously run spec files in the same serialized run. invalidatePermissionCache(); // The superuser needs BOTH sides of the RBAC split: the dynamic // isSuperuser role (roleDocs) for resolveLeadershipTransfer's guard and the // legacy admin role for the transfers lib's hasRoles-based notifySuperusers. const superRole = await makeRole("super", { isSuperuser: true }); users.boss = await makeUser("boss-su", { roles: ["admin"], roleDocs: [superRole.id] }); // Make sure the destination starts unconfigured for the unset-error test, // regardless of what earlier files in the same serialized run left behind. await payload.updateGlobal({ slug: "game-rules", data: { infantryAssignment: null }, overrideAccess: true, }); users.leaderX = await makeUser("lead-x"); users.leaderY = await makeUser("lead-y"); users.member1 = await makeUser("member-1"); users.member2 = await makeUser("member-2"); users.member3 = await makeUser("member-3"); users.memberInf = await makeUser("member-inf"); users.infantryLead = await makeUser("inf-lead"); users.outsider = await makeUser("outsider"); await makeAssignment("Division X", "division", users.leaderX.id, [ users.member1.id, users.member2.id, users.member3.id, ]); await makeAssignment("Division Y", "division", users.leaderY.id, []); await makeAssignment("Squad S", "squad", users.leaderX.id, [users.member1.id]); await makeAssignment("Infantry", "division", users.infantryLead.id, [users.memberInf.id]); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Restore the global so other spec files in the same run are unaffected. await payload.updateGlobal({ slug: "game-rules", data: { infantryAssignment: null }, overrideAccess: true, }); const notifs = await payload .find({ collection: "user-notifications", where: { user: { in: userIds } }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const n of notifs?.docs ?? []) { await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {}); } const events = await payload .find({ collection: "game-event-logs", where: { or: [ { actor: { in: userIds } }, { targetCollection: { equals: "leadership-transfers" } }, ], }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const e of events?.docs ?? []) { await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {}); } for (const id of transferIds) { await payload.delete({ collection: "leadership-transfers", id, overrideAccess: true }).catch(() => {}); } for (const id of assignmentIds) { await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { const profiles = await payload .find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true }) .catch(() => null); for (const p of profiles?.docs ?? []) { await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {}); } const accounts = await payload .find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const a of accounts?.docs ?? []) { await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } }); it("enforces creation guards", async () => { const divisionX = assignmentIdsByName["Division X"]; // Non-leader initiator. await expect( requestLeadershipTransfer(payload, users.outsider, { subjectId: users.member1.id, fromAssignmentId: divisionX, }), ).rejects.toThrow("Only the current leader of"); // Leader of a different assignment. await expect( requestLeadershipTransfer(payload, users.leaderY, { subjectId: users.member1.id, fromAssignmentId: divisionX, }), ).rejects.toThrow("Only the current leader of"); // Squad-type source (leader is right, assignment type is wrong). await expect( requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member1.id, fromAssignmentId: assignmentIdsByName["Squad S"], }), ).rejects.toThrow("Only divisions can remove members this way."); // Self-removal. await expect( requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.leaderX.id, fromAssignmentId: divisionX, }), ).rejects.toThrow("You cannot remove yourself."); // Subject is not a member of the division. await expect( requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.outsider.id, fromAssignmentId: divisionX, }), ).rejects.toThrow("That member is not a member of your division."); }, TIMEOUT); it("rejects requests while the Infantry destination is not configured", async () => { await expect( requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member1.id, fromAssignmentId: assignmentIdsByName["Division X"], reason: "presence", }), ).rejects.toThrow("Command must configure the default transfer destination first (Game Rules)."); }, TIMEOUT); it("rejects infantry-origin removals", async () => { const infantry = assignmentIdsByName["Infantry"]; await payload.updateGlobal({ slug: "game-rules", data: { infantryAssignment: infantry }, overrideAccess: true, }); await expect( requestLeadershipTransfer(payload, users.infantryLead, { subjectId: users.memberInf.id, fromAssignmentId: infantry, }), ).rejects.toThrow("Infantry-origin removals are not supported yet."); }, TIMEOUT); it("creates a pending request, notifies only superusers, and emits no event", async () => { const doc = await requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member1.id, fromAssignmentId: assignmentIdsByName["Division X"], reason: "presence", }); transferIds.push(doc.id); expect(doc.status).toBe("pending"); expect(doc.toAssignment).toBe(assignmentIdsByName["Infantry"]); expect(doc.reason).toBe("presence"); const bossNotified = await notificationsFor(users.boss.id, "assignment:leadership-transfer-requested"); expect(bossNotified.length).toBeGreaterThan(0); expect(bossNotified[0]?.title).toBe("Removal request"); // The subject learns nothing while the request is pending. const subjectNotified = await notificationsFor(users.member1.id); expect(subjectNotified.length).toBe(0); // No public event-log entry while pending. expect((await leadershipEventsFor(doc.id)).length).toBe(0); // A second open request for the same member + division is rejected. await expect( requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member1.id, fromAssignmentId: assignmentIdsByName["Division X"], }), ).rejects.toThrow("A removal request for this member is already pending."); // Stash for the approval test. pendingDoc = doc; }, TIMEOUT); it("superuser approval moves the member and attributes everything to the leader", async () => { const doc = pendingDoc!; const divisionX = assignmentIdsByName["Division X"]; const infantry = assignmentIdsByName["Infantry"]; // Non-superusers cannot resolve. await expect( resolveLeadershipTransfer(payload, users.leaderY, doc.id, { approve: true }), ).rejects.toThrow("Only a superuser can resolve removal requests."); const approved = await resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: true, note: "agreed", }); expect(approved.status).toBe("approved"); expect(approved.reviewedBy).toBe(users.boss.id); expect(approved.resolvedAt).toBeTruthy(); expect(approved.reviewNote).toBe("agreed"); const from = await getAssignment(divisionX); const to = await getAssignment(infantry); expect(from.members).not.toContain(users.member1.id); expect(to.members).toContain(users.member1.id); // The subject's notification names the LEADER, never the approving superuser. const subjectNotified = await notificationsFor(users.member1.id, "assignment:leadership-transfer"); expect(subjectNotified.length).toBeGreaterThan(0); const subjectMessage = subjectNotified[0]?.message ?? ""; expect(subjectMessage).toContain(users.leaderX.displayName); expect(subjectMessage).not.toContain(users.boss.displayName); const initiatorNotified = await notificationsFor( users.leaderX.id, "assignment:leadership-transfer-approved", ); expect(initiatorNotified.length).toBeGreaterThan(0); // Exactly one public event, visibly authored by the leader; the resolver's // id lives in data for audit only. const events = await leadershipEventsFor(doc.id); expect(events.length).toBe(1); const event = events[0]!; expect(event.type).toBe(EventTypes.PersonnelTransferred); expect(event.actor).toBe(users.leaderX.id); const data = event.data as Record; expect(data.initiatorId).toBe(users.leaderX.id); expect(data.approvedById).toBe(users.boss.id); expect(data.subjectId).toBe(users.member1.id); expect(data.fromAssignmentId).toBe(divisionX); expect(data.toAssignmentId).toBe(infantry); expect(data.reason).toBe("presence"); // A second resolution attempt is rejected (no longer pending). await expect( resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: true }), ).rejects.toThrow("Only pending removal requests can be resolved"); // The collection hook rejects illegal transitions out of the terminal state. await expect( payload.update({ collection: "leadership-transfers", id: doc.id, data: { status: "pending" }, overrideAccess: true, }), ).rejects.toThrow("Illegal leadership transfer status transition"); }, TIMEOUT); it("denial moves nothing, notifies only the initiator, and emits no event", async () => { const divisionX = assignmentIdsByName["Division X"]; const doc = await requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member2.id, fromAssignmentId: divisionX, }); transferIds.push(doc.id); const denied = await resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: false, note: "keep them", }); expect(denied.status).toBe("denied"); expect(denied.reviewedBy).toBe(users.boss.id); expect(denied.resolvedAt).toBeTruthy(); expect(denied.reviewNote).toBe("keep them"); const from = await getAssignment(divisionX); const to = await getAssignment(assignmentIdsByName["Infantry"]); expect(from.members).toContain(users.member2.id); expect(to.members).not.toContain(users.member2.id); const initiatorNotified = await notificationsFor( users.leaderX.id, "assignment:leadership-transfer-denied", ); expect(initiatorNotified.length).toBeGreaterThan(0); expect(initiatorNotified[0]?.message).toContain("keep them"); // The subject is not told about a denial, and nothing goes public. expect((await notificationsFor(users.member2.id, "assignment:leadership-transfer")).length).toBe(0); expect((await leadershipEventsFor(doc.id)).length).toBe(0); }, TIMEOUT); it("cancels own pending requests only", async () => { const divisionX = assignmentIdsByName["Division X"]; const doc = await requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member3.id, fromAssignmentId: divisionX, }); transferIds.push(doc.id); // Another leader cannot cancel someone else's request. await expect( cancelLeadershipTransfer(payload, users.leaderY, doc.id), ).rejects.toThrow("Only your own pending requests can be cancelled."); const cancelled = await cancelLeadershipTransfer(payload, users.leaderX, doc.id); expect(cancelled.status).toBe("cancelled"); // Cancelled is terminal: no second cancellation, and no membership moved. await expect(cancelLeadershipTransfer(payload, users.leaderX, doc.id)).rejects.toThrow( "Only your own pending requests can be cancelled.", ); const from = await getAssignment(divisionX); const to = await getAssignment(assignmentIdsByName["Infantry"]); expect(from.members).toContain(users.member3.id); expect(to.members).not.toContain(users.member3.id); // A cancelled request is not "open": a fresh one for the same member is allowed. const second = await requestLeadershipTransfer(payload, users.leaderX, { subjectId: users.member3.id, fromAssignmentId: divisionX, }); transferIds.push(second.id); expect(second.status).toBe("pending"); await cancelLeadershipTransfer(payload, users.leaderX, second.id); }, TIMEOUT); });