- 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
411 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|