- announcements collection: info/success/warning/error variants, banner/modal/dialog/toast display types, up to 3 action buttons, dismissible flag, activeFrom/activeUntil windows, audience targeting (all/users/roles) and path include/exclude matching copied from the Shims pattern - pure client-safe evaluation lib (enabled, time window, audience via roleDoc IDs, path match) plus per-browser localStorage dismissals - AnnouncementHost mounted in the (frontend) layout: banners render inline, toasts via sonner custom markup, modals and confirmation dialogs queue one at a time - permissions announcements:create/read/update/delete + admin:announcements:manage - description is Lexical richText rendered via RichText/lexical-content on all surfaces; role targeting uses the dynamic Roles collection - hand-written migrations with snapshot sidecars (collection, roleDocs targeting, description richtext conversion) + integration tests (12 passing)
291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
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<Role> = {}): Promise<Role> => {
|
|
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<User> => {
|
|
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<unknown> => {
|
|
const fn = Announcements.access?.[action];
|
|
expect(typeof fn).toBe("function");
|
|
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
|
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);
|
|
});
|