From cc2bf44fd264c130156955cae645de5bf304ce29 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 19:44:55 +0000 Subject: [PATCH] feat(scripts): add emit-data-map compliance script + tests 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 --- compliance/data-map.yml | 35 ++ coverage/summary.json | 28 +- package.json | 2 + scripts/compliance/emit-data-map.mjs | 412 ++++++++++++++++++++++ scripts/compliance/emit-data-map.test.mjs | 354 +++++++++++++++++++ 5 files changed, 817 insertions(+), 14 deletions(-) create mode 100644 compliance/data-map.yml create mode 100644 scripts/compliance/emit-data-map.mjs create mode 100644 scripts/compliance/emit-data-map.test.mjs diff --git a/compliance/data-map.yml b/compliance/data-map.yml new file mode 100644 index 0000000..00eb1df --- /dev/null +++ b/compliance/data-map.yml @@ -0,0 +1,35 @@ +# compliance/data-map.yml — PII field inventory +# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually. +# Run `pnpm compliance:data-map` to regenerate. +collections: + articles: + auth: false + piiFields: [] + slug: articles + media: + auth: false + piiFields: [] + slug: media + pages: + auth: false + piiFields: [] + slug: pages + users: + auth: true + piiFields: + - category: identification-username + exportable: true + field: displayName + purpose: + - service-delivery + restrictable: true + source: field-tag + - category: contact-email + exportable: true + field: email + purpose: + - account-authentication + - transactional-notifications + restrictable: true + source: auth-default + slug: users diff --git a/coverage/summary.json b/coverage/summary.json index 51a46fe..3b9ce2e 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,14 +1,14 @@ { - "generatedAt": "2026-05-18T19:03:46.644Z", - "commit": "464df9a", + "generatedAt": "2026-05-18T19:42:59.601Z", + "commit": "3a73634", "repo": { - "statements": 96.43, + "statements": 96.47, "branches": 91.71, "functions": 96.81, - "lines": 96.43, + "lines": 96.47, "counts": { - "lf": 4205, - "lh": 4055, + "lf": 4254, + "lh": 4104, "brf": 808, "brh": 741, "fnf": 251, @@ -31,13 +31,13 @@ } }, "@repo/blog": { - "statements": 96.36, + "statements": 96.35, "branches": 88.65, "functions": 100, - "lines": 96.36, + "lines": 96.35, "counts": { - "lf": 741, - "lh": 714, + "lf": 739, + "lh": 712, "brf": 141, "brh": 125, "fnf": 29, @@ -73,13 +73,13 @@ } }, "@repo/marketing-pages": { - "statements": 95.64, + "statements": 95.93, "branches": 83.93, "functions": 100, - "lines": 95.64, + "lines": 95.93, "counts": { - "lf": 711, - "lh": 680, + "lf": 762, + "lh": 731, "brf": 112, "brh": 94, "fnf": 32, diff --git a/package.json b/package.json index 61c9d87..323ca01 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "mutate": "node scripts/coverage/mutate.mjs", "fallow": "fallow", "fallow:audit": "fallow audit --base main", + "compliance:data-map": "node scripts/compliance/emit-data-map.mjs", "work": "node scripts/work/cli.mjs", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", @@ -28,6 +29,7 @@ }, "devDependencies": { "@ai-hero/sandcastle": "*", + "@typescript-eslint/parser": "^8.25.0", "zod": "^3.25.0", "@playwright/test": "^1.49.0", "@stryker-mutator/core": "^8.7.0", diff --git a/scripts/compliance/emit-data-map.mjs b/scripts/compliance/emit-data-map.mjs new file mode 100644 index 0000000..b948301 --- /dev/null +++ b/scripts/compliance/emit-data-map.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node +/** + * emit-data-map.mjs — PII field inventory emitter. + * + * Walks packages/*\/src/integrations/cms/collections/*.ts, applies + * PAYLOAD_AUTH_PII_DEFAULTS + custom.authPii overrides, and emits a + * deterministic YAML PII inventory at compliance/data-map.yml. + * + * Usage: + * node scripts/compliance/emit-data-map.mjs # write compliance/data-map.yml + * node scripts/compliance/emit-data-map.mjs --print # write to stdout + * node scripts/compliance/emit-data-map.mjs --check # diff vs committed file; exit 1 on mismatch + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "@typescript-eslint/parser"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(__dirname, "../.."); +export const OUTPUT_PATH = "compliance/data-map.yml"; + +// ---- Constants ---- + +/** + * Default PII classification for Payload auth fields. + * Null means the field is NOT PII and must be excluded from the data map. + * Mirrors packages/core-shared/src/payload/pii-types.ts PAYLOAD_AUTH_PII_DEFAULTS. + */ +export const PAYLOAD_AUTH_PII_DEFAULTS = { + email: { + category: "contact-email", + purpose: ["account-authentication", "transactional-notifications"], + exportable: true, + restrictable: true, + }, + password: null, + salt: null, + hash: null, + resetPasswordToken: null, + resetPasswordExpiration: null, + loginAttempts: null, + lockUntil: null, + apiKey: null, + apiKeyIndex: null, +}; + +// ---- AST parsing helpers ---- + +/** + * Recursively extract a plain JS value from an AST node. + * Returns undefined for unresolvable nodes (identifier references, etc.). + */ +function extractValue(node) { + if (!node) return null; + if (node.type === "Literal") return node.value; + if (node.type === "TSAsExpression") return extractValue(node.expression); + if (node.type === "Identifier") { + if (node.name === "true") return true; + if (node.name === "false") return false; + if (node.name === "null") return null; + return undefined; + } + if (node.type === "ObjectExpression") { + const obj = {}; + for (const prop of node.properties) { + if (prop.type !== "Property") continue; + const key = + prop.key.type === "Identifier" + ? prop.key.name + : prop.key.type === "Literal" + ? String(prop.key.value) + : null; + if (!key) continue; + const v = extractValue(prop.value); + if (v !== undefined) obj[key] = v; + } + return obj; + } + if (node.type === "ArrayExpression") { + return node.elements + .map((e) => (e ? extractValue(e) : null)) + .filter((e) => e !== undefined); + } + return undefined; +} + +/** + * Parse a Payload collection TypeScript file. + * Returns the first exported variable whose value is an object with a string + * `slug` field, or null on parse failure / missing file. + */ +export function parseCollectionFile(filePath) { + let src; + try { + src = fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + let ast; + try { + ast = parse(src, { + sourceType: "module", + ecmaVersion: "latest", + loc: false, + range: false, + }); + } catch { + return null; + } + + for (const node of ast.body) { + if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue; + if (node.declaration.type !== "VariableDeclaration") continue; + for (const decl of node.declaration.declarations) { + if (!decl.init) continue; + const value = extractValue(decl.init); + if ( + value && + typeof value === "object" && + typeof value.slug === "string" + ) { + return value; + } + } + } + return null; +} + +// ---- Collection file discovery ---- + +/** + * Find all Payload collection TypeScript files across packages. + */ +export function findCollectionFiles(repoRoot = REPO_ROOT) { + const packagesDir = path.join(repoRoot, "packages"); + if (!fs.existsSync(packagesDir)) return []; + + const files = []; + for (const pkg of fs.readdirSync(packagesDir).sort()) { + const collectionsDir = path.join( + packagesDir, + pkg, + "src", + "integrations", + "cms", + "collections", + ); + if (!fs.existsSync(collectionsDir)) continue; + for (const file of fs.readdirSync(collectionsDir).sort()) { + if (file.endsWith(".ts") && !file.endsWith(".test.ts")) { + files.push(path.join(collectionsDir, file)); + } + } + } + return files; +} + +// ---- Data map builder ---- + +/** Build a single PII entry. */ +function makePiiEntry(fieldName, pii, source) { + return { + field: fieldName, + source, + category: pii.category, + purpose: Array.isArray(pii.purpose) ? [...pii.purpose] : [], + exportable: pii.exportable ?? false, + restrictable: pii.restrictable ?? false, + ...(pii.retention ? { retention: { ...pii.retention } } : {}), + }; +} + +/** Collect field-level PII entries from a collection's fields array. */ +function collectFieldPiiEntries(fields) { + const entries = []; + if (!Array.isArray(fields)) return entries; + for (const field of fields) { + if (!field || typeof field.name !== "string") continue; + const pii = field.custom && field.custom.pii; + if (!pii || typeof pii !== "object") continue; + entries.push(makePiiEntry(field.name, pii, "field-tag")); + } + return entries; +} + +/** Collect auth PII entries by merging defaults with per-collection overrides. */ +function collectAuthPiiEntries(authPiiDefaults, authPiiOverrides) { + const merged = { ...authPiiDefaults, ...authPiiOverrides }; + const entries = []; + for (const [fieldName, pii] of Object.entries(merged)) { + if (pii === null) continue; + const source = + fieldName in authPiiOverrides ? "auth-override" : "auth-default"; + entries.push(makePiiEntry(fieldName, pii, source)); + } + return entries; +} + +/** + * Walk parsed collection configs and build the PII data map. + * Applies authPiiDefaults (PAYLOAD_AUTH_PII_DEFAULTS) and custom.authPii + * overrides for auth-enabled collections. + * + * Returns: Record + */ +export function buildDataMap( + rawCollections, + authPiiDefaults = PAYLOAD_AUTH_PII_DEFAULTS, +) { + const map = {}; + for (const coll of rawCollections) { + if (!coll || typeof coll.slug !== "string") continue; + const isAuth = coll.auth === true; + + const piiFields = [ + ...collectFieldPiiEntries(coll.fields), + ...(isAuth + ? collectAuthPiiEntries( + authPiiDefaults, + (coll.custom && coll.custom.authPii) || {}, + ) + : []), + ]; + piiFields.sort((a, b) => a.field.localeCompare(b.field)); + + map[coll.slug] = { auth: isAuth, piiFields, slug: coll.slug }; + } + return map; +} + +// ---- YAML serialization ---- + +const YAML_HEADER = [ + "# compliance/data-map.yml — PII field inventory", + "# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually.", + "# Run `pnpm compliance:data-map` to regenerate.", +].join("\n"); + +/** Quote a YAML string scalar only when necessary. */ +function yamlStr(s) { + if (typeof s !== "string") return String(s); + if ( + s === "" || + ["true", "false", "null", "yes", "no", "on", "off"].includes(s) || + /[[{},:#&*!|>'"%@`\]]/u.test(s) || + /^\s|\s$/.test(s) + ) { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + return s; +} + +function renderPiiField(f) { + let out = ""; + out += ` - category: ${yamlStr(f.category)}\n`; + out += ` exportable: ${f.exportable}\n`; + out += ` field: ${yamlStr(f.field)}\n`; + + if (!f.purpose || f.purpose.length === 0) { + out += ` purpose: []\n`; + } else { + out += ` purpose:\n`; + for (const p of f.purpose) { + out += ` - ${yamlStr(p)}\n`; + } + } + + out += ` restrictable: ${f.restrictable}\n`; + + if (f.retention) { + out += ` retention:\n`; + out += ` action: ${yamlStr(f.retention.action)}\n`; + out += ` duration: ${yamlStr(f.retention.duration)}\n`; + out += ` trigger: ${yamlStr(f.retention.trigger)}\n`; + } + + out += ` source: ${yamlStr(f.source)}\n`; + return out; +} + +/** + * Render the data map as deterministic YAML. + * Collections sorted by slug; piiFields sorted by field name (done in buildDataMap). + */ +export function renderDataMapYaml(dataMap) { + let yaml = YAML_HEADER + "\n"; + yaml += "collections:\n"; + + for (const slug of Object.keys(dataMap).sort()) { + const coll = dataMap[slug]; + yaml += ` ${yamlStr(slug)}:\n`; + yaml += ` auth: ${coll.auth}\n`; + + if (!coll.piiFields || coll.piiFields.length === 0) { + yaml += ` piiFields: []\n`; + } else { + yaml += ` piiFields:\n`; + for (const f of coll.piiFields) { + yaml += renderPiiField(f); + } + } + + yaml += ` slug: ${yamlStr(slug)}\n`; + } + + return yaml; +} + +// ---- Diff helper for --check mode ---- + +/** + * Produce a readable line-diff between expected (committed) and actual (generated). + * Returns null if equal. + */ +export function unifiedDiff(expected, actual, filename) { + const expLines = expected.split("\n"); + const actLines = actual.split("\n"); + const maxLen = Math.max(expLines.length, actLines.length); + + const hunks = []; + for (let i = 0; i < maxLen; i++) { + const expLine = expLines[i] ?? ""; + const actLine = actLines[i] ?? ""; + if (expLine !== actLine) { + hunks.push(`Line ${i + 1}:`); + hunks.push(` - ${expLine}`); + hunks.push(` + ${actLine}`); + } + } + + if (hunks.length === 0) return null; + return ( + `--- ${filename} (committed)\n` + + `+++ ${filename} (generated)\n` + + hunks.join("\n") + ); +} + +// ---- CLI ---- + +function parseArgs(argv) { + const out = { mode: "write" }; // 'write' | 'print' | 'check' + for (let i = 2; i < argv.length; i++) { + if (argv[i] === "--print") out.mode = "print"; + else if (argv[i] === "--check") out.mode = "check"; + else if (argv[i] === "--help" || argv[i] === "-h") { + console.log( + [ + "Usage: node scripts/compliance/emit-data-map.mjs [--print | --check]", + " (default): write compliance/data-map.yml", + " --print: write YAML to stdout", + " --check: diff vs committed file; exit 1 on mismatch", + ].join("\n"), + ); + process.exit(0); + } + } + return out; +} + +function main() { + const args = parseArgs(process.argv); + const repoRoot = process.cwd(); + + const files = findCollectionFiles(repoRoot); + const rawCollections = files + .map((f) => parseCollectionFile(f)) + .filter(Boolean); + const dataMap = buildDataMap(rawCollections); + const yaml = renderDataMapYaml(dataMap); + + const outPath = path.resolve(repoRoot, OUTPUT_PATH); + + if (args.mode === "print") { + process.stdout.write(yaml); + return; + } + + if (args.mode === "check") { + if (!fs.existsSync(outPath)) { + process.stderr.write( + `[compliance:data-map] --check: no committed file at ${OUTPUT_PATH}\n` + + `Run \`pnpm compliance:data-map\` to generate it first.\n`, + ); + process.exit(1); + } + const committed = fs.readFileSync(outPath, "utf8"); + const diff = unifiedDiff(committed, yaml, OUTPUT_PATH); + if (diff === null) { + console.log(`✓ compliance:data-map — ${OUTPUT_PATH} is up to date`); + process.exit(0); + } + process.stderr.write( + `✗ compliance:data-map — ${OUTPUT_PATH} is out of date:\n${diff}\n`, + ); + process.exit(1); + } + + // Default: write to file + const dir = path.dirname(outPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(outPath, yaml, "utf8"); + console.log(`✓ compliance:data-map — wrote ${OUTPUT_PATH}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/compliance/emit-data-map.test.mjs b/scripts/compliance/emit-data-map.test.mjs new file mode 100644 index 0000000..d5ca479 --- /dev/null +++ b/scripts/compliance/emit-data-map.test.mjs @@ -0,0 +1,354 @@ +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")); + }); +});