Adds scripts/compliance/emit-data-map.mjs which walks Payload collection configs (packages/*/integrations/cms/collections/*.ts), applies PAYLOAD_AUTH_PII_DEFAULTS + custom.authPii overrides, and emits a deterministic YAML PII inventory at compliance/data-map.yml. Supports --print (stdout) and --check (diff vs committed, exit 1 on mismatch) modes. Ships with 26 unit tests covering happy path, auth defaults, authPii overrides, --check match/mismatch, and empty collections. Wired as `compliance:data-map` root package script. Adds @typescript-eslint/parser to root devDependencies (already in workspace via core-eslint, now made explicit for scripts/ usage). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
355 lines
11 KiB
JavaScript
355 lines
11 KiB
JavaScript
import { test, describe } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
findCollectionFiles,
|
|
parseCollectionFile,
|
|
buildDataMap,
|
|
renderDataMapYaml,
|
|
unifiedDiff,
|
|
} from "./emit-data-map.mjs";
|
|
|
|
// ---- Fixtures ----
|
|
|
|
const USERS_TS = `
|
|
import type { CollectionConfig } from "payload";
|
|
export const users: CollectionConfig = {
|
|
slug: "users",
|
|
auth: true,
|
|
custom: {
|
|
retention: { purgeSchedule: "daily", postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" } },
|
|
},
|
|
fields: [
|
|
{
|
|
name: "displayName",
|
|
type: "text",
|
|
custom: {
|
|
pii: {
|
|
category: "identification-username",
|
|
purpose: ["service-delivery"],
|
|
exportable: true,
|
|
restrictable: true,
|
|
},
|
|
},
|
|
},
|
|
{ name: "role", type: "select" },
|
|
],
|
|
};
|
|
`;
|
|
|
|
const ARTICLES_TS = `
|
|
import type { CollectionConfig } from "payload";
|
|
export const articles: CollectionConfig = {
|
|
slug: "articles",
|
|
custom: {
|
|
retention: { purgeSchedule: "monthly", postDeletion: { duration: "P90D", trigger: "after-deletion", action: "hard-delete" } },
|
|
},
|
|
fields: [
|
|
{ name: "title", type: "text" },
|
|
{ name: "content", type: "richText" },
|
|
],
|
|
};
|
|
`;
|
|
|
|
const AUTH_PII_OVERRIDE_TS = `
|
|
import type { CollectionConfig } from "payload";
|
|
export const members: CollectionConfig = {
|
|
slug: "members",
|
|
auth: true,
|
|
custom: {
|
|
retention: { purgeSchedule: "daily" },
|
|
authPii: {
|
|
email: {
|
|
category: "contact-email",
|
|
purpose: ["account-authentication", "marketing-communications"],
|
|
exportable: false,
|
|
restrictable: true,
|
|
},
|
|
password: null,
|
|
},
|
|
},
|
|
fields: [],
|
|
};
|
|
`;
|
|
|
|
// ---- Test helpers ----
|
|
|
|
function makeRepo(collections) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-"));
|
|
for (const [pkgSlug, src] of Object.entries(collections)) {
|
|
const dir = path.join(
|
|
root,
|
|
"packages",
|
|
pkgSlug,
|
|
"src",
|
|
"integrations",
|
|
"cms",
|
|
"collections",
|
|
);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, `${pkgSlug}.ts`), src, "utf8");
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function buildMapFromFixtures(fixtures) {
|
|
const root = makeRepo(fixtures);
|
|
const files = findCollectionFiles(root);
|
|
const raw = files.map((f) => parseCollectionFile(f)).filter(Boolean);
|
|
return buildDataMap(raw);
|
|
}
|
|
|
|
// ---- Tests ----
|
|
|
|
describe("findCollectionFiles", () => {
|
|
test("returns all .ts files in packages/*/integrations/cms/collections/", () => {
|
|
const root = makeRepo({ blog: ARTICLES_TS, auth: USERS_TS });
|
|
const files = findCollectionFiles(root);
|
|
assert.equal(files.length, 2);
|
|
assert.ok(files.every((f) => f.endsWith(".ts")));
|
|
assert.ok(files.some((f) => f.includes("auth")));
|
|
assert.ok(files.some((f) => f.includes("blog")));
|
|
});
|
|
|
|
test("returns empty array when packages/ does not exist", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-empty-"));
|
|
assert.deepEqual(findCollectionFiles(root), []);
|
|
});
|
|
|
|
test("excludes .test.ts files", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-test-"));
|
|
const dir = path.join(
|
|
root,
|
|
"packages",
|
|
"blog",
|
|
"src",
|
|
"integrations",
|
|
"cms",
|
|
"collections",
|
|
);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, "articles.ts"), ARTICLES_TS, "utf8");
|
|
fs.writeFileSync(path.join(dir, "articles.test.ts"), "// test", "utf8");
|
|
const files = findCollectionFiles(root);
|
|
assert.equal(files.length, 1);
|
|
assert.ok(!files[0].endsWith(".test.ts"));
|
|
});
|
|
});
|
|
|
|
describe("parseCollectionFile", () => {
|
|
test("extracts slug, auth, fields, and custom from a collection file", () => {
|
|
const root = makeRepo({ auth: USERS_TS });
|
|
const [file] = findCollectionFiles(root);
|
|
const coll = parseCollectionFile(file);
|
|
assert.equal(coll.slug, "users");
|
|
assert.equal(coll.auth, true);
|
|
assert.ok(Array.isArray(coll.fields));
|
|
assert.equal(coll.fields.length, 2);
|
|
assert.equal(coll.fields[0].name, "displayName");
|
|
assert.equal(coll.fields[0].custom.pii.category, "identification-username");
|
|
});
|
|
|
|
test("returns null for an unreadable path", () => {
|
|
assert.equal(parseCollectionFile("/nonexistent/path.ts"), null);
|
|
});
|
|
|
|
test("extracts a collection without auth or PII", () => {
|
|
const root = makeRepo({ blog: ARTICLES_TS });
|
|
const [file] = findCollectionFiles(root);
|
|
const coll = parseCollectionFile(file);
|
|
assert.equal(coll.slug, "articles");
|
|
assert.equal(coll.auth, undefined);
|
|
assert.equal(coll.fields.length, 2);
|
|
assert.ok(!coll.fields[0].custom);
|
|
});
|
|
});
|
|
|
|
describe("buildDataMap — empty collections", () => {
|
|
test("returns empty map for no collections", () => {
|
|
assert.deepEqual(buildDataMap([]), {});
|
|
});
|
|
|
|
test("includes collection with no PII fields as piiFields: []", () => {
|
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
|
assert.ok("articles" in map);
|
|
assert.deepEqual(map.articles.piiFields, []);
|
|
assert.equal(map.articles.auth, false);
|
|
});
|
|
});
|
|
|
|
describe("buildDataMap — auth defaults applied", () => {
|
|
test("applies PAYLOAD_AUTH_PII_DEFAULTS for auth-enabled collections", () => {
|
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
|
const emailField = map.users.piiFields.find((f) => f.field === "email");
|
|
assert.ok(emailField, "email field should be present from auth defaults");
|
|
assert.equal(emailField.category, "contact-email");
|
|
assert.equal(emailField.source, "auth-default");
|
|
assert.deepEqual(emailField.purpose, [
|
|
"account-authentication",
|
|
"transactional-notifications",
|
|
]);
|
|
});
|
|
|
|
test("excludes null-valued auth defaults (e.g. password, salt)", () => {
|
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
|
const nullFields = ["password", "salt", "hash", "resetPasswordToken"];
|
|
for (const name of nullFields) {
|
|
const found = map.users.piiFields.find((f) => f.field === name);
|
|
assert.equal(
|
|
found,
|
|
undefined,
|
|
`${name} should be excluded (null default)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("does NOT apply auth defaults for non-auth collections", () => {
|
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
|
assert.deepEqual(map.articles.piiFields, []);
|
|
});
|
|
|
|
test("includes field-level PII tags alongside auth defaults", () => {
|
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
|
const displayName = map.users.piiFields.find(
|
|
(f) => f.field === "displayName",
|
|
);
|
|
const email = map.users.piiFields.find((f) => f.field === "email");
|
|
assert.ok(displayName, "displayName should come from field-tag");
|
|
assert.equal(displayName.source, "field-tag");
|
|
assert.ok(email, "email should come from auth-default");
|
|
assert.equal(email.source, "auth-default");
|
|
});
|
|
|
|
test("piiFields are sorted by field name", () => {
|
|
const map = buildMapFromFixtures({ auth: USERS_TS });
|
|
const names = map.users.piiFields.map((f) => f.field);
|
|
assert.deepEqual(names, [...names].sort());
|
|
});
|
|
});
|
|
|
|
describe("buildDataMap — authPii override applied", () => {
|
|
test("custom.authPii overrides PAYLOAD_AUTH_PII_DEFAULTS", () => {
|
|
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
|
|
const emailField = map.members.piiFields.find((f) => f.field === "email");
|
|
assert.ok(emailField, "email should be present");
|
|
assert.equal(emailField.source, "auth-override");
|
|
assert.equal(emailField.exportable, false);
|
|
assert.ok(
|
|
emailField.purpose.includes("marketing-communications"),
|
|
"overridden purpose should be used",
|
|
);
|
|
});
|
|
|
|
test("authPii null overrides exclude the field", () => {
|
|
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
|
|
const pwField = map.members.piiFields.find((f) => f.field === "password");
|
|
assert.equal(
|
|
pwField,
|
|
undefined,
|
|
"password null override should be excluded",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("renderDataMapYaml", () => {
|
|
test("renders collections in alphabetical order", () => {
|
|
const map = {
|
|
users: { auth: true, piiFields: [], slug: "users" },
|
|
articles: { auth: false, piiFields: [], slug: "articles" },
|
|
};
|
|
const yaml = renderDataMapYaml(map);
|
|
const articlesIdx = yaml.indexOf(" articles:");
|
|
const usersIdx = yaml.indexOf(" users:");
|
|
assert.ok(articlesIdx < usersIdx, "articles should come before users");
|
|
});
|
|
|
|
test("renders piiFields: [] for empty fields", () => {
|
|
const map = { articles: { auth: false, piiFields: [], slug: "articles" } };
|
|
const yaml = renderDataMapYaml(map);
|
|
assert.ok(yaml.includes("piiFields: []"));
|
|
});
|
|
|
|
test("renders PII field with all required keys", () => {
|
|
const map = {
|
|
users: {
|
|
auth: true,
|
|
slug: "users",
|
|
piiFields: [
|
|
{
|
|
field: "email",
|
|
source: "auth-default",
|
|
category: "contact-email",
|
|
purpose: ["account-authentication"],
|
|
exportable: true,
|
|
restrictable: true,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const yaml = renderDataMapYaml(map);
|
|
assert.ok(yaml.includes("field: email"));
|
|
assert.ok(yaml.includes("category: contact-email"));
|
|
assert.ok(yaml.includes("source: auth-default"));
|
|
assert.ok(yaml.includes("exportable: true"));
|
|
assert.ok(yaml.includes("restrictable: true"));
|
|
assert.ok(yaml.includes("- account-authentication"));
|
|
});
|
|
|
|
test("rendered YAML includes header comment", () => {
|
|
const yaml = renderDataMapYaml({});
|
|
assert.ok(yaml.startsWith("# compliance/data-map.yml"));
|
|
});
|
|
|
|
test("output is deterministic across multiple calls", () => {
|
|
const map = buildMapFromFixtures({ auth: USERS_TS, blog: ARTICLES_TS });
|
|
assert.equal(renderDataMapYaml(map), renderDataMapYaml(map));
|
|
});
|
|
});
|
|
|
|
describe("unifiedDiff", () => {
|
|
test("returns null when strings are equal", () => {
|
|
assert.equal(unifiedDiff("a\nb\n", "a\nb\n", "file.yml"), null);
|
|
});
|
|
|
|
test("returns a readable diff when strings differ", () => {
|
|
const diff = unifiedDiff("line1\nold\n", "line1\nnew\n", "file.yml");
|
|
assert.ok(diff !== null);
|
|
assert.ok(diff.includes("--- file.yml"));
|
|
assert.ok(diff.includes("+++ file.yml"));
|
|
assert.ok(diff.includes("- old"));
|
|
assert.ok(diff.includes("+ new"));
|
|
});
|
|
|
|
test("diff includes line numbers", () => {
|
|
const diff = unifiedDiff("a\n", "b\n", "x.yml");
|
|
assert.ok(diff.includes("Line 1:"));
|
|
});
|
|
|
|
test("handles different lengths (extra lines)", () => {
|
|
const diff = unifiedDiff("a\nb\n", "a\nb\nc\n", "x.yml");
|
|
assert.ok(diff !== null);
|
|
assert.ok(diff.includes("+ c"));
|
|
});
|
|
});
|
|
|
|
describe("--check mode (integration)", () => {
|
|
test("passes when committed file matches generated output", () => {
|
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
|
const yaml = renderDataMapYaml(map);
|
|
const diff = unifiedDiff(yaml, yaml, "compliance/data-map.yml");
|
|
assert.equal(diff, null, "no diff expected when file matches");
|
|
});
|
|
|
|
test("fails when committed file does not match generated output", () => {
|
|
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
|
|
const yaml = renderDataMapYaml(map);
|
|
const stale = "# stale content\ncollections: {}\n";
|
|
const diff = unifiedDiff(stale, yaml, "compliance/data-map.yml");
|
|
assert.ok(diff !== null, "diff expected when file is stale");
|
|
assert.ok(diff.includes("- # stale content"));
|
|
});
|
|
});
|