1
0
Fork 0
polaris-task-force/tests/int/surveys.int.spec.ts
Z8MB1E 20b65d08fc feat(surveys): add survey system with audience targeting on plugin-form-builder
- forms collection: targeting group (all/users/roles/qualifications), activeFrom/activeUntil window, radio/date field blocks enabled
- form-submissions: server-side user attribution, expiry and audience gates, one attempt per user
- forms/submissions permissions in the Surveys registry group + admin role seed grants
- frontend /surveys list and /surveys/[id] take page with client block renderer
- register the pending role permission enum values migration
2026-09-23 18:17:29 -04:00

411 lines
14 KiB
TypeScript

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<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",
...(roleId ? { roleDocs: [roleId] } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const makeForm = async (label: string, extra: Partial<Form> = {}): Promise<Form> => {
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<Qualification> => {
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<void> => {
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<Role> = {}): Promise<Role> => {
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<User> => {
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<unknown> => {
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<unknown>)({
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);
});
});