import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { Announcements } from "@/collections/Announcements"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { isAnnouncementActive, isWithinWindow, matchesAudience, matchesPath, type AnnouncementEntry, } from "@/lib/announcements/evaluate"; import { readAnnouncementDismissals, storeAnnouncementDismissal, writeAnnouncementDismissals, } from "@/lib/announcements/dismissals"; let payload: Payload; const RUN = `ann-${Date.now().toString(36)}`; const TIMEOUT = 30_000; const NOW = 1_700_000_000_000; const HOUR = 60 * 60 * 1000; const base: AnnouncementEntry = { id: 1, title: "Test announcement", variant: "info", displayType: "banner", enabled: true, dismissible: true, targeting: { audience: "all" }, paths: { matchType: "all" }, }; describe("announcement evaluation (pure)", () => { it("treats missing bounds as an open window", () => { expect(isWithinWindow({ ...base, activeFrom: null, activeUntil: null }, NOW)).toBe(true); }); it("respects activeFrom and activeUntil", () => { expect(isWithinWindow({ ...base, activeFrom: new Date(NOW + HOUR).toISOString() }, NOW)).toBe( false, ); expect(isWithinWindow({ ...base, activeFrom: new Date(NOW - HOUR).toISOString() }, NOW)).toBe( true, ); expect(isWithinWindow({ ...base, activeUntil: new Date(NOW).toISOString() }, NOW)).toBe(false); expect( isWithinWindow({ ...base, activeUntil: new Date(NOW + HOUR).toISOString() }, NOW), ).toBe(true); }); it("audience all matches anyone; users matches by id; roles matches any listed roleDoc id", () => { expect(matchesAudience({ ...base, targeting: { audience: "all" } }, 7, [])).toBe(true); expect( matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 9, []), ).toBe(true); expect( matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 8, []), ).toBe(false); expect(matchesAudience({ ...base, targeting: { audience: "users" } }, null, [])).toBe(false); expect( matchesAudience( { ...base, targeting: { audience: "users", users: [{ id: 7 }] as never } }, 7, [], ), ).toBe(true); expect( matchesAudience( { ...base, targeting: { audience: "roles", roles: [3, 5] } }, null, [2], ), ).toBe(false); expect( matchesAudience( { ...base, targeting: { audience: "roles", roles: [3, 5] } }, 1, [2, 5], ), ).toBe(true); expect( matchesAudience({ ...base, targeting: { audience: "roles" } }, 1, []), ).toBe(false); }); it("isAnnouncementActive requires enabled", () => { expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe( false, ); expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true); }); it("path matching supports all, include prefixes, and exclude", () => { expect(matchesPath("/logistics/market", { matchType: "all" })).toBe(true); expect( matchesPath("/logistics/market", { matchType: "include", paths: ["/logistics"] }), ).toBe(true); expect(matchesPath("/wiki/some-page", { matchType: "include", paths: ["/logistics"] })).toBe( false, ); expect( matchesPath("/wiki/some-page", { matchType: "exclude", paths: ["/logistics"] }), ).toBe(true); expect( matchesPath("/logistics/market", { matchType: "exclude", paths: ["/logistics"] }), ).toBe(false); expect(matchesPath("/logistics", { matchType: "include", paths: ["/logistics"] })).toBe(true); expect( matchesPath("/logistics-market", { matchType: "include", paths: ["/logistics"] }), ).toBe(false); }); it("isAnnouncementActive requires enabled", () => { expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe( false, ); expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true); }); }); describe("announcement dismissals (localStorage)", () => { beforeEach(() => { window.localStorage.clear(); }); it("roundtrips dismissals and drops expired entries on read", () => { writeAnnouncementDismissals({ "1": NOW + HOUR, "2": NOW - HOUR }, NOW); const read = readAnnouncementDismissals(NOW); expect(read["1"]).toBe(NOW + HOUR); expect(read["2"]).toBeUndefined(); }); it("storeAnnouncementDismissal expires at activeUntil when set", () => { storeAnnouncementDismissal(5, new Date(NOW + 2 * HOUR).toISOString(), NOW); storeAnnouncementDismissal(6, new Date(NOW - 2 * HOUR).toISOString(), NOW); storeAnnouncementDismissal(7, null, NOW); const read = readAnnouncementDismissals(NOW); expect(read["5"]).toBe(NOW + 2 * HOUR); // Expired activeUntil still yields the 30-day fallback. expect(read["6"]).toBeGreaterThan(NOW); expect(read["7"]).toBeGreaterThan(NOW + 29 * 24 * HOUR); }); }); describe("announcements collection access control", () => { const roleIds: number[] = []; const userIds: number[] = []; const announcementIds: number[] = []; let permittedUser: User; let plainUser: User; let devUser: User; 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, roleId: number): 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", roleDocs: [roleId], }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const accessDecision = async ( action: "create" | "read" | "update" | "delete", user: User | null, ): Promise => { const fn = Announcements.access?.[action]; expect(typeof fn).toBe("function"); return await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload }, }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); const permittedRole = await makeRole("permitted", { permissions: [ "announcements:create", "announcements:read", "announcements:update", "announcements:delete", ], }); const plainRole = await makeRole("plain", { permissions: [] }); const devRole = await makeRole("dev", { isSuperuser: true }); permittedUser = await makeUser("permitted", permittedRole.id); plainUser = await makeUser("plain", plainRole.id); devUser = await makeUser("dev", devRole.id); }, TIMEOUT); afterAll(async () => { if (!payload) return; for (const id of announcementIds) { await payload .delete({ collection: "announcements", id, overrideAccess: true }) .catch(() => {}); } for (const id of userIds) { await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); it("grants CRUD to roles holding the announcements permissions", async () => { expect(await accessDecision("read", permittedUser)).toBe(true); expect(await accessDecision("create", permittedUser)).toBe(true); expect(await accessDecision("update", permittedUser)).toBe(true); expect(await accessDecision("delete", permittedUser)).toBe(true); }); it("denies users without announcements permissions", async () => { expect(await accessDecision("read", plainUser)).toBe(false); expect(await accessDecision("create", plainUser)).toBe(false); }); it("grants superusers everything and denies anonymous users", async () => { expect(await accessDecision("read", devUser)).toBe(true); expect(await accessDecision("delete", devUser)).toBe(true); expect(await accessDecision("read", null)).toBe(false); expect(await accessDecision("create", null)).toBe(false); }); it("roundtrips an announcement through the local API", async () => { const created = (await payload.create({ collection: "announcements", data: { title: `${RUN} maintenance window`, variant: "warning", displayType: "banner", enabled: true, dismissible: false, targeting: { audience: "roles", roles: [roleIds[0]] }, paths: { matchType: "exclude", paths: ["/admin"] }, }, overrideAccess: true, depth: 0, })) as { id: number }; announcementIds.push(created.id); const found = await payload.find({ collection: "announcements", where: { enabled: { equals: true } }, limit: 50, depth: 0, overrideAccess: true, }); const doc = found.docs.find((d) => d.id === created.id); expect(doc).toBeDefined(); expect(doc?.title).toBe(`${RUN} maintenance window`); expect((doc as { targeting: { roles: number[] } }).targeting.roles).toContain(roleIds[0]); await payload.delete({ collection: "announcements", id: created.id, overrideAccess: true }); announcementIds.pop(); }, TIMEOUT); });