import { getPayload, type Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Form, Qualification, Role, User } from "@/payload-types"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; let payload: Payload; const RUN = `survey-${Date.now().toString(36)}`; const TIMEOUT = 30_000; const NOW = 1_700_000_000_000; const HOUR = 60 * 60 * 1000; const roleIds: number[] = []; const userIds: number[] = []; const formIds: number[] = []; const submissionIds: number[] = []; const qualificationIds: number[] = []; const profileIds: number[] = []; let rankId: number; describe("survey evaluation (pure)", () => { it("treats missing bounds as an open window", () => { expect(isSurveyOpen({ activeFrom: null, activeUntil: null }, NOW)).toBe(true); }); it("respects activeFrom and activeUntil", () => { expect(isSurveyOpen({ activeFrom: new Date(NOW + HOUR).toISOString(), activeUntil: null }, NOW)).toBe(false); expect(isSurveyOpen({ activeFrom: new Date(NOW - HOUR).toISOString(), activeUntil: null }, NOW)).toBe(true); expect(isSurveyOpen({ activeFrom: null, activeUntil: new Date(NOW).toISOString() }, NOW)).toBe(false); expect(isSurveyOpen({ activeFrom: null, activeUntil: new Date(NOW + HOUR).toISOString() }, NOW)).toBe(true); }); it("audience all matches anyone; users by id; roles by roleDoc id", () => { const ctx = (userId: number, roleIds: number[] = []) => ({ userId, roleIds, qualificationNames: [], }); expect(surveyAudienceMatches({ targeting: { audience: "all" } }, ctx(7))).toBe(true); expect(surveyAudienceMatches({ targeting: { audience: "users", users: [7, 9] } }, ctx(9))).toBe(true); expect(surveyAudienceMatches({ targeting: { audience: "users", users: [7, 9] } }, ctx(8))).toBe(false); expect(surveyAudienceMatches({ targeting: { audience: "roles", roles: [3, 5] } }, ctx(1, [2]))).toBe(false); expect(surveyAudienceMatches({ targeting: { audience: "roles", roles: [3, 5] } }, ctx(1, [2, 5]))).toBe(true); expect(surveyAudienceMatches({ targeting: { audience: "roles" } }, ctx(1, []))).toBe(false); }); it("qualification audience matches by case-insensitive name", () => { const ctx = (names: string[]) => ({ userId: 1, roleIds: [], qualificationNames: names }); expect( surveyAudienceMatches( { targeting: { audience: "qualifications", qualifications: [{ id: 1, name: "Parachutist" } as unknown as Qualification] } }, ctx(["parachutist"]), ), ).toBe(true); expect( surveyAudienceMatches( { targeting: { audience: "qualifications", qualifications: [{ id: 1, name: "Parachutist" } as unknown as Qualification] } }, ctx(["sniper"]), ), ).toBe(false); expect(surveyAudienceMatches({ targeting: { audience: "qualifications" } }, ctx(["anything"]))).toBe(false); }); }); describe("survey submission gates", () => { let memberUser: User; let roleAUser: User; let roleBUser: User; let qualifiedUser: User; let unqualifiedUser: User; let roleA: Role; let roleB: Role; let openForm: Form; let closedForm: Form; let futureForm: Form; let usersForm: Form; let rolesForm: Form; let qualsForm: Form; 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", ...(roleId ? { roleDocs: [roleId] } : {}), }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const makeForm = async (label: string, extra: Partial
= {}): Promise => { const form = (await payload.create({ collection: "forms", data: { title: `${RUN}-${label}`, fields: [{ blockType: "text", name: "q1", label: "Q1" }], targeting: { audience: "all" }, confirmationType: "message", confirmationMessage: { root: { type: "root", format: "", indent: 0, version: 1, direction: "ltr", children: [ { type: "paragraph", format: "", indent: 0, version: 1, direction: "ltr", children: [ { type: "text", format: 0, style: "", mode: "normal", text: "Thanks!", version: 1, }, ], }, ], }, }, ...extra, }, overrideAccess: true, depth: 0, })) as unknown as Form; formIds.push(form.id); return form; }; const makeQualification = async (name: string): Promise => { const qual = (await payload.create({ collection: "qualifications", data: { name }, overrideAccess: true, depth: 0, })) as unknown as Qualification; qualificationIds.push(qual.id); return qual; }; const makeProfile = async (user: User, qualificationIdsForProfile: number[]): Promise => { const profile = await payload.create({ collection: "profiles", data: { user: user.id, rank: rankId, dossier: { enlistmentDate: new Date(NOW).toISOString() }, progression: { qualifications: qualificationIdsForProfile }, }, overrideAccess: true, depth: 0, }); profileIds.push(profile.id); }; const submit = (formId: number, user: User) => payload.create({ collection: "form-submissions", data: { form: formId, submissionData: [{ field: "q1", value: "yes" }] }, user, overrideAccess: false, depth: 0, }); beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); const rank = await payload.create({ collection: "ranks", data: { name: `${RUN} Rank`, abbreviation: "TS", description: `${RUN} test rank` }, overrideAccess: true, depth: 0, }); rankId = rank.id; const plainRole = await makeRole("plain"); roleA = await makeRole("role-a"); roleB = await makeRole("role-b"); memberUser = await makeUser("member", plainRole.id); roleAUser = await makeUser("role-a", roleA.id); roleBUser = await makeUser("role-b", roleB.id); const qual = await makeQualification(`${RUN}-parachutist`); qualifiedUser = await makeUser("qualified", plainRole.id); unqualifiedUser = await makeUser("unqualified", plainRole.id); await makeProfile(qualifiedUser, [qual.id]); await makeProfile(unqualifiedUser, []); openForm = await makeForm("open"); closedForm = await makeForm("closed", { activeUntil: new Date(Date.now() - HOUR).toISOString(), }); futureForm = await makeForm("future", { activeFrom: new Date(Date.now() + HOUR).toISOString(), }); usersForm = await makeForm("users", { targeting: { audience: "users", users: [memberUser.id] }, }); rolesForm = await makeForm("roles", { targeting: { audience: "roles", roles: [roleA.id] }, }); qualsForm = await makeForm("quals", { targeting: { audience: "qualifications", qualifications: [qual.id] }, }); }, TIMEOUT); afterAll(async () => { if (!payload) return; for (const id of submissionIds) { await payload.delete({ collection: "form-submissions", id, overrideAccess: true }).catch(() => {}); } for (const id of profileIds) { await payload.delete({ collection: "profiles", id, overrideAccess: true }).catch(() => {}); } for (const id of qualificationIds) { await payload.delete({ collection: "qualifications", id, overrideAccess: true }).catch(() => {}); } for (const id of formIds) { await payload.delete({ collection: "forms", 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("attributes the submission to the submitter and records the answers", async () => { const submission = (await submit(openForm.id, memberUser)) as unknown as { id: number; user: number; submissionData: { field: string; value: string }[]; }; submissionIds.push(submission.id); expect(submission.user).toBe(memberUser.id); expect(submission.submissionData).toMatchObject([{ field: "q1", value: "yes" }]); }, TIMEOUT); it("enforces one attempt per user while allowing other users", async () => { await expect(submit(openForm.id, memberUser)).rejects.toThrow(/already submitted/); const other = (await submit(openForm.id, roleAUser)) as unknown as { id: number }; submissionIds.push(other.id); }, TIMEOUT); it("rejects submissions outside the availability window", async () => { await expect(submit(closedForm.id, memberUser)).rejects.toThrow(/closed/); await expect(submit(futureForm.id, memberUser)).rejects.toThrow(/not open yet/); }, TIMEOUT); it("enforces user-list audience", async () => { const allowed = (await submit(usersForm.id, memberUser)) as unknown as { id: number }; submissionIds.push(allowed.id); await expect(submit(usersForm.id, roleAUser)).rejects.toThrow(/not eligible/); }, TIMEOUT); it("enforces role audience via roleDocs", async () => { const allowed = (await submit(rolesForm.id, roleAUser)) as unknown as { id: number }; submissionIds.push(allowed.id); await expect(submit(rolesForm.id, roleBUser)).rejects.toThrow(/not eligible/); }, TIMEOUT); it("enforces qualification audience via the user's profile", async () => { const allowed = (await submit(qualsForm.id, qualifiedUser)) as unknown as { id: number }; submissionIds.push(allowed.id); await expect(submit(qualsForm.id, unqualifiedUser)).rejects.toThrow(/not eligible/); }, TIMEOUT); }); describe("survey collection access control", () => { let staffUser: User; let plainUser: User; let staffRole: Role; const makeRole = async (label: string, extra: Partial = {}): Promise => { const role = (await payload.create({ collection: "roles", data: { name: `${RUN}-acl-${label}`, slug: `${RUN}-acl-${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}-acl-${label}`, discordUsername: `${RUN}-acl-${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 ( collection: "forms" | "form-submissions", action: "create" | "read" | "update" | "delete", user: User | null, pathname?: string, ): Promise => { const builtConfig = await config; const target = builtConfig.collections.find((c) => c.slug === collection); expect(target).toBeDefined(); const fn = target?.access?.[action]; expect(typeof fn).toBe("function"); return await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname }, }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); staffRole = await makeRole("staff", { permissions: [ "forms:create", "forms:read", "forms:update", "forms:delete", "form-submissions:read", "form-submissions:delete", "admin:forms:manage", "admin:form-submissions:manage", ], }); const plainRole = await makeRole("plain-acl", { permissions: [] }); staffUser = await makeUser("staff", staffRole.id); plainUser = await makeUser("plain", plainRole.id); }, TIMEOUT); it("forms REST reads are logged-in; authoring needs the permissions", async () => { expect(await accessDecision("forms", "read", null)).toBe(false); expect(await accessDecision("forms", "read", plainUser)).toBe(true); expect(await accessDecision("forms", "create", plainUser)).toBe(false); expect(await accessDecision("forms", "create", staffUser)).toBe(true); }); it("forms admin-page access needs read AND admin manage", async () => { expect(await accessDecision("forms", "read", plainUser, "/admin")).toBe(false); expect(await accessDecision("forms", "read", staffUser, "/admin")).toBe(true); }); it("submissions REST reads need form-submissions:read", async () => { expect(await accessDecision("form-submissions", "read", null)).toBe(false); expect(await accessDecision("form-submissions", "read", plainUser)).toBe(false); expect(await accessDecision("form-submissions", "read", staffUser)).toBe(true); }); it("submissions admin-page access needs read AND admin manage", async () => { expect(await accessDecision("form-submissions", "read", plainUser, "/admin")).toBe(false); expect(await accessDecision("form-submissions", "read", staffUser, "/admin")).toBe(true); }); it("submissions create requires a user; delete needs the permission", async () => { expect(await accessDecision("form-submissions", "create", null)).toBe(false); expect(await accessDecision("form-submissions", "create", plainUser)).toBe(true); expect(await accessDecision("form-submissions", "delete", staffUser)).toBe(true); expect(await accessDecision("form-submissions", "delete", plainUser)).toBe(false); }); });