diff --git a/package.json b/package.json index 9241da8..ee8f17a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "fallow:audit": "fallow audit --base main", "compliance:data-map": "node scripts/compliance/emit-data-map.mjs", "compliance:retention-policy": "node scripts/compliance/emit-retention-policy.mjs", + "compliance:sub-processors": "node scripts/compliance/emit-sub-processors.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}\"", diff --git a/scripts/compliance/emit-sub-processors.mjs b/scripts/compliance/emit-sub-processors.mjs new file mode 100644 index 0000000..5bf3265 --- /dev/null +++ b/scripts/compliance/emit-sub-processors.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node +/** + * emit-sub-processors.mjs — Third-party sub-processor inventory emitter. + * + * Walks docs/library-decisions/*.md, filters entries where is-sub-processor: true, + * merges compliance/sub-processors.manual.yml (if present) with source: manual flag, + * and emits a sorted deterministic YAML inventory at compliance/sub-processors.yml. + * + * Usage: + * node scripts/compliance/emit-sub-processors.mjs # write compliance/sub-processors.yml + * node scripts/compliance/emit-sub-processors.mjs --print # write to stdout + * node scripts/compliance/emit-sub-processors.mjs --check # diff vs committed file; exit 1 on mismatch + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { unifiedDiff, REPO_ROOT } from "./emit-data-map.mjs"; + +export { unifiedDiff, REPO_ROOT }; + +export const OUTPUT_PATH = "compliance/sub-processors.yml"; +export const MANUAL_PATH = "compliance/sub-processors.manual.yml"; + +// ---- Frontmatter parser ---- + +/** Parse a YAML scalar value string into its JS equivalent. */ +function parseScalarValue(raw) { + if (raw === "true") return true; + if (raw === "false") return false; + if (raw === "null") return null; + if ( + (raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'")) + ) { + return raw.slice(1, -1); + } + return raw; +} + +/** + * Parse top-level scalar fields from YAML frontmatter in a markdown file. + * Returns an object of key → value pairs, or null if no frontmatter is found. + * Skips comment lines, empty lines, and indented lines (nested block fields). + */ +export function parseFrontmatter(src) { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(src); + if (!match) return null; + + const result = {}; + for (const line of match[1].split("\n")) { + if (!line.trim() || line.startsWith("#")) continue; + if (line.startsWith(" ") || line.startsWith("\t")) continue; + + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + const rawValue = line.slice(colonIdx + 1).trim(); + if (!rawValue) continue; // block header like "filter-results:" + + result[key] = parseScalarValue(rawValue); + } + return result; +} + +// ---- Library decision file discovery ---- + +/** + * Find all library decision markdown files under docs/library-decisions/. + * Excludes _template.md and any file starting with `_`. + */ +export function findLibraryDecisionFiles(repoRoot = REPO_ROOT) { + const dir = path.join(repoRoot, "docs", "library-decisions"); + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir) + .sort() + .filter((f) => f.endsWith(".md") && !f.startsWith("_")) + .map((f) => path.join(dir, f)); +} + +// ---- Sub-processor parsing from library traces ---- + +const TRACE_FIELDS = [ + "package", + "version", + "decision", + "data-sent", + "region", + "dpa-signed", + "sccs-required", + "contact", +]; + +/** + * Walk library decision files and return sub-processor entries where + * is-sub-processor: true. Each entry gets source: "library-trace" injected. + */ +export function parseLibraryTraceSubProcessors(repoRoot = REPO_ROOT) { + const files = findLibraryDecisionFiles(repoRoot); + const entries = []; + + for (const filePath of files) { + let src; + try { + src = fs.readFileSync(filePath, "utf8"); + } catch { + continue; + } + + const meta = parseFrontmatter(src); + if (!meta || meta["is-sub-processor"] !== true) continue; + + const entry = { source: "library-trace" }; + for (const field of TRACE_FIELDS) { + if (meta[field] !== undefined) entry[field] = meta[field]; + } + entries.push(entry); + } + + return entries; +} + +// ---- Manual entries ---- + +/** + * Parse a simple YAML list-of-objects: each item starts with "- key: value" + * and continuation lines are indented with spaces or tabs. + * Returns an array of plain objects. + */ +export function parseSimpleYamlList(src) { + const entries = []; + let current = null; + + for (const line of src.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (line.startsWith("- ")) { + if (current) entries.push(current); + current = {}; + const rest = line.slice(2).trim(); + const colonIdx = rest.indexOf(":"); + if (colonIdx !== -1) { + const key = rest.slice(0, colonIdx).trim(); + const rawVal = rest.slice(colonIdx + 1).trim(); + if (rawVal) current[key] = parseScalarValue(rawVal); + } + } else if (current && (line.startsWith(" ") || line.startsWith("\t"))) { + const colonIdx = line.indexOf(":"); + if (colonIdx !== -1) { + const key = line.slice(0, colonIdx).trim(); + const rawVal = line.slice(colonIdx + 1).trim(); + if (rawVal) current[key] = parseScalarValue(rawVal); + } + } + } + if (current) entries.push(current); + return entries; +} + +/** + * Load manual sub-processor entries from compliance/sub-processors.manual.yml. + * Returns empty array if the file doesn't exist (graceful skip). + * Injects source: "manual" into each entry. + */ +export function loadManualEntries(repoRoot = REPO_ROOT) { + const manualPath = path.resolve(repoRoot, MANUAL_PATH); + if (!fs.existsSync(manualPath)) return []; + + let src; + try { + src = fs.readFileSync(manualPath, "utf8"); + } catch { + return []; + } + + return parseSimpleYamlList(src).map((entry) => ({ + ...entry, + source: "manual", + })); +} + +// ---- Builder ---- + +/** + * Merge library-trace and manual sub-processor entries. + * Sorts by package name for deterministic output. + */ +export function buildSubProcessors(traced, manual) { + const all = [...traced, ...manual]; + all.sort((a, b) => + String(a.package ?? "").localeCompare(String(b.package ?? "")), + ); + return all; +} + +// ---- YAML serialization ---- + +const YAML_HEADER = [ + "# compliance/sub-processors.yml — Third-party sub-processor inventory", + "# Generated by scripts/compliance/emit-sub-processors.mjs — do not edit manually.", + "# Run `pnpm compliance:sub-processors` 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; +} + +// package first (natural identifier), then remaining fields alphabetically +const ENTRY_FIELDS = [ + "package", + "contact", + "data-sent", + "decision", + "dpa-signed", + "region", + "sccs-required", + "source", + "version", +]; + +/** + * Render sub-processors as deterministic YAML. + * Entries sorted by package name (done in buildSubProcessors). + * Fields rendered in a fixed order: package first, rest alphabetical. + */ +export function renderSubProcessorsYaml(entries) { + let yaml = YAML_HEADER + "\n"; + yaml += "sub-processors:\n"; + + if (entries.length === 0) { + yaml += " []\n"; + return yaml; + } + + for (const entry of entries) { + let first = true; + for (const field of ENTRY_FIELDS) { + const value = entry[field]; + if (value === undefined) continue; + + const rendered = + typeof value === "boolean" ? String(value) : yamlStr(String(value)); + + if (first) { + yaml += ` - ${field}: ${rendered}\n`; + first = false; + } else { + yaml += ` ${field}: ${rendered}\n`; + } + } + } + + return yaml; +} + +// ---- 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-sub-processors.mjs [--print | --check]", + " (default): write compliance/sub-processors.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 traced = parseLibraryTraceSubProcessors(repoRoot); + const manual = loadManualEntries(repoRoot); + const entries = buildSubProcessors(traced, manual); + const yaml = renderSubProcessorsYaml(entries); + + 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:sub-processors] --check: no committed file at ${OUTPUT_PATH}\n` + + `Run \`pnpm compliance:sub-processors\` 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:sub-processors — ${OUTPUT_PATH} is up to date`); + process.exit(0); + } + process.stderr.write( + `✗ compliance:sub-processors — ${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:sub-processors — wrote ${OUTPUT_PATH}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/compliance/emit-sub-processors.test.mjs b/scripts/compliance/emit-sub-processors.test.mjs new file mode 100644 index 0000000..1ae0a10 --- /dev/null +++ b/scripts/compliance/emit-sub-processors.test.mjs @@ -0,0 +1,512 @@ +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 { + parseFrontmatter, + findLibraryDecisionFiles, + parseLibraryTraceSubProcessors, + parseSimpleYamlList, + loadManualEntries, + buildSubProcessors, + renderSubProcessorsYaml, + unifiedDiff, + OUTPUT_PATH, + MANUAL_PATH, +} from "./emit-sub-processors.mjs"; + +// ---- Fixtures ---- + +const SUB_PROCESSOR_MD = `--- +package: stripe +version: "^14.0.0" +tier: feature +decision: approved +date: 2026-05-18 +deciders: [Danijel Martinek] +adr: null +lastRevalidated: null +is-sub-processor: true +processes-pii: true +data-sent: payment card details and billing address +region: eu-west-1 +dpa-signed: true +sccs-required: false +contact: https://stripe.com/privacy +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: ok + cve-scan: clean + named-consumer: pass + socketRisk: clean +verification-commands: + - npm view stripe license +accepted-cves: [] +--- + +## Filter: license + +MIT. +`; + +const NON_SUB_PROCESSOR_MD = `--- +package: zod +version: "^3.0.0" +tier: core +decision: approved +date: 2026-05-14 +deciders: [Danijel Martinek] +adr: null +lastRevalidated: null +is-sub-processor: false +processes-pii: false +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: n/a + cve-scan: clean + named-consumer: pass + socketRisk: clean +verification-commands: + - npm view zod license +accepted-cves: [] +--- +`; + +const ANOTHER_SUB_PROCESSOR_MD = `--- +package: sendgrid +version: "^7.0.0" +tier: feature +decision: approved +date: 2026-05-18 +deciders: [Danijel Martinek] +adr: null +lastRevalidated: null +is-sub-processor: true +processes-pii: true +data-sent: email address and name for transactional emails +region: eu +dpa-signed: false +sccs-required: true +contact: https://sendgrid.com/privacy +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: ok + cve-scan: clean + named-consumer: pass + socketRisk: clean +verification-commands: + - npm view @sendgrid/mail license +accepted-cves: [] +--- +`; + +const MANUAL_YAML = `- package: aws-s3 + data-sent: file uploads and user avatars + region: eu-west-1 + dpa-signed: true + sccs-required: false + contact: https://aws.amazon.com/compliance/eu-data-privacy/ + decision: approved + version: "^3.0.0" +`; + +// ---- Test helpers ---- + +function makeLibraryDecisions(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-sub-processors-")); + const dir = path.join(root, "docs", "library-decisions"); + fs.mkdirSync(dir, { recursive: true }); + for (const [name, src] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), src, "utf8"); + } + return root; +} + +function makeRepoWithManual(libraryFiles, manualYaml) { + const root = makeLibraryDecisions(libraryFiles); + if (manualYaml !== undefined) { + const complianceDir = path.join(root, "compliance"); + fs.mkdirSync(complianceDir, { recursive: true }); + fs.writeFileSync(path.join(root, MANUAL_PATH), manualYaml, "utf8"); + } + return root; +} + +// ---- Tests ---- + +describe("parseFrontmatter", () => { + test("parses top-level scalar fields", () => { + const meta = parseFrontmatter(SUB_PROCESSOR_MD); + assert.equal(meta.package, "stripe"); + assert.equal(meta["is-sub-processor"], true); + assert.equal(meta["dpa-signed"], true); + assert.equal(meta["sccs-required"], false); + assert.equal(meta.decision, "approved"); + assert.equal(meta.contact, "https://stripe.com/privacy"); + }); + + test("parses false boolean correctly (is-sub-processor: false)", () => { + const meta = parseFrontmatter(NON_SUB_PROCESSOR_MD); + assert.equal(meta["is-sub-processor"], false); + assert.equal(meta["processes-pii"], false); + }); + + test("strips quotes from version strings", () => { + const meta = parseFrontmatter(SUB_PROCESSOR_MD); + assert.equal(meta.version, "^14.0.0"); + }); + + test("skips nested block fields (filter-results)", () => { + const meta = parseFrontmatter(SUB_PROCESSOR_MD); + assert.ok( + !("license" in meta), + "should not include nested filter-results.license", + ); + assert.ok( + !("filter-results" in meta), + "filter-results block header should be skipped", + ); + }); + + test("returns null when no frontmatter is present", () => { + assert.equal(parseFrontmatter("no frontmatter here"), null); + }); + + test("returns null for empty string", () => { + assert.equal(parseFrontmatter(""), null); + }); +}); + +describe("findLibraryDecisionFiles", () => { + test("finds all .md files and excludes _template.md", () => { + const root = makeLibraryDecisions({ + "2026-05-14-stripe.md": SUB_PROCESSOR_MD, + "2026-05-14-zod.md": NON_SUB_PROCESSOR_MD, + "_template.md": "# template", + }); + const files = findLibraryDecisionFiles(root); + assert.equal(files.length, 2); + assert.ok(files.every((f) => !path.basename(f).startsWith("_"))); + }); + + test("returns files in sorted order", () => { + const root = makeLibraryDecisions({ + "2026-05-18-stripe.md": SUB_PROCESSOR_MD, + "2026-05-14-zod.md": NON_SUB_PROCESSOR_MD, + }); + const files = findLibraryDecisionFiles(root); + assert.ok( + path.basename(files[0]) < path.basename(files[1]), + "files should be sorted alphabetically", + ); + }); + + test("returns empty array when docs/library-decisions does not exist", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "empty-repo-")); + assert.deepEqual(findLibraryDecisionFiles(root), []); + }); +}); + +describe("parseLibraryTraceSubProcessors — discriminated union", () => { + test("returns only entries where is-sub-processor: true", () => { + const root = makeLibraryDecisions({ + "2026-05-14-stripe.md": SUB_PROCESSOR_MD, + "2026-05-14-zod.md": NON_SUB_PROCESSOR_MD, + }); + const entries = parseLibraryTraceSubProcessors(root); + assert.equal(entries.length, 1); + assert.equal(entries[0].package, "stripe"); + }); + + test("sets source to library-trace for parsed entries", () => { + const root = makeLibraryDecisions({ + "2026-05-14-stripe.md": SUB_PROCESSOR_MD, + }); + const [entry] = parseLibraryTraceSubProcessors(root); + assert.equal(entry.source, "library-trace"); + }); + + test("extracts all sub-processor fields from frontmatter", () => { + const root = makeLibraryDecisions({ + "2026-05-14-stripe.md": SUB_PROCESSOR_MD, + }); + const [entry] = parseLibraryTraceSubProcessors(root); + assert.equal(entry.package, "stripe"); + assert.equal(entry.version, "^14.0.0"); + assert.equal(entry.decision, "approved"); + assert.equal( + entry["data-sent"], + "payment card details and billing address", + ); + assert.equal(entry.region, "eu-west-1"); + assert.equal(entry["dpa-signed"], true); + assert.equal(entry["sccs-required"], false); + assert.equal(entry.contact, "https://stripe.com/privacy"); + }); + + test("returns empty array when no sub-processors exist", () => { + const root = makeLibraryDecisions({ + "2026-05-14-zod.md": NON_SUB_PROCESSOR_MD, + }); + assert.deepEqual(parseLibraryTraceSubProcessors(root), []); + }); + + test("returns empty array when library-decisions dir is absent", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "no-decisions-")); + assert.deepEqual(parseLibraryTraceSubProcessors(root), []); + }); + + test("handles multiple sub-processor files", () => { + const root = makeLibraryDecisions({ + "2026-05-14-stripe.md": SUB_PROCESSOR_MD, + "2026-05-14-sendgrid.md": ANOTHER_SUB_PROCESSOR_MD, + "2026-05-14-zod.md": NON_SUB_PROCESSOR_MD, + }); + const entries = parseLibraryTraceSubProcessors(root); + assert.equal(entries.length, 2); + const packages = entries.map((e) => e.package).sort(); + assert.deepEqual(packages, ["sendgrid", "stripe"]); + }); +}); + +describe("parseSimpleYamlList", () => { + test("parses a simple YAML list into an array of objects", () => { + const entries = parseSimpleYamlList(MANUAL_YAML); + assert.equal(entries.length, 1); + assert.equal(entries[0].package, "aws-s3"); + assert.equal(entries[0]["dpa-signed"], true); + assert.equal(entries[0]["sccs-required"], false); + assert.equal(entries[0].region, "eu-west-1"); + }); + + test("strips quotes from quoted values", () => { + const entries = parseSimpleYamlList(MANUAL_YAML); + assert.equal(entries[0].version, "^3.0.0"); + }); + + test("parses multiple list items", () => { + const src = `- package: alpha + region: eu +- package: beta + region: us +`; + const entries = parseSimpleYamlList(src); + assert.equal(entries.length, 2); + assert.equal(entries[0].package, "alpha"); + assert.equal(entries[1].package, "beta"); + }); + + test("returns empty array for empty input", () => { + assert.deepEqual(parseSimpleYamlList(""), []); + }); + + test("returns empty array for comment-only input", () => { + assert.deepEqual( + parseSimpleYamlList("# just a comment\n# another line"), + [], + ); + }); +}); + +describe("loadManualEntries", () => { + test("returns empty array when manual file is absent (graceful skip)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "no-manual-")); + assert.deepEqual(loadManualEntries(root), []); + }); + + test("loads manual entries and injects source: manual", () => { + const root = makeRepoWithManual({}, MANUAL_YAML); + const entries = loadManualEntries(root); + assert.equal(entries.length, 1); + assert.equal(entries[0].package, "aws-s3"); + assert.equal(entries[0].source, "manual"); + }); + + test("preserves all fields from manual file", () => { + const root = makeRepoWithManual({}, MANUAL_YAML); + const [entry] = loadManualEntries(root); + assert.equal(entry["data-sent"], "file uploads and user avatars"); + assert.equal(entry["dpa-signed"], true); + assert.equal(entry["sccs-required"], false); + assert.equal(entry.decision, "approved"); + }); +}); + +describe("buildSubProcessors — merge and sort", () => { + test("merges traced and manual entries sorted by package name", () => { + const traced = [{ package: "stripe", source: "library-trace" }]; + const manual = [{ package: "aws-s3", source: "manual" }]; + const merged = buildSubProcessors(traced, manual); + assert.equal(merged.length, 2); + assert.equal(merged[0].package, "aws-s3"); + assert.equal(merged[1].package, "stripe"); + }); + + test("preserves source field for each entry type", () => { + const traced = [{ package: "stripe", source: "library-trace" }]; + const manual = [{ package: "aws-s3", source: "manual" }]; + const merged = buildSubProcessors(traced, manual); + assert.equal(merged[0].source, "manual"); + assert.equal(merged[1].source, "library-trace"); + }); + + test("returns empty array when both inputs are empty", () => { + assert.deepEqual(buildSubProcessors([], []), []); + }); + + test("returns traced-only when no manual entries", () => { + const traced = [{ package: "stripe", source: "library-trace" }]; + const merged = buildSubProcessors(traced, []); + assert.equal(merged.length, 1); + assert.equal(merged[0].source, "library-trace"); + }); + + test("returns manual-only when no traced entries", () => { + const manual = [{ package: "aws-s3", source: "manual" }]; + const merged = buildSubProcessors([], manual); + assert.equal(merged.length, 1); + assert.equal(merged[0].source, "manual"); + }); + + test("sorts multiple entries alphabetically by package", () => { + const traced = [ + { package: "zod", source: "library-trace" }, + { package: "stripe", source: "library-trace" }, + ]; + const merged = buildSubProcessors(traced, []); + assert.equal(merged[0].package, "stripe"); + assert.equal(merged[1].package, "zod"); + }); +}); + +describe("renderSubProcessorsYaml", () => { + test("renders YAML header comment", () => { + const yaml = renderSubProcessorsYaml([]); + assert.ok(yaml.startsWith("# compliance/sub-processors.yml")); + assert.ok(yaml.includes("emit-sub-processors.mjs")); + }); + + test("renders empty list as sub-processors: []", () => { + const yaml = renderSubProcessorsYaml([]); + assert.ok(yaml.includes("sub-processors:")); + assert.ok(yaml.includes(" []")); + }); + + test("renders entries in package-name order", () => { + const entries = [ + { package: "aws-s3", source: "manual", decision: "approved" }, + { package: "stripe", source: "library-trace", decision: "approved" }, + ]; + const yaml = renderSubProcessorsYaml(entries); + const awsIdx = yaml.indexOf("package: aws-s3"); + const stripeIdx = yaml.indexOf("package: stripe"); + assert.ok(awsIdx < stripeIdx, "aws-s3 should appear before stripe"); + }); + + test("renders all defined fields per entry", () => { + const entries = [ + { + package: "stripe", + version: "^14.0.0", + decision: "approved", + "data-sent": "payment card details", + region: "eu-west-1", + "dpa-signed": true, + "sccs-required": false, + contact: "https://stripe.com/privacy", + source: "library-trace", + }, + ]; + const yaml = renderSubProcessorsYaml(entries); + assert.ok(yaml.includes("package: stripe")); + assert.ok(yaml.includes("version: ^14.0.0")); + assert.ok(yaml.includes("decision: approved")); + assert.ok(yaml.includes("data-sent: payment card details")); + assert.ok(yaml.includes("region: eu-west-1")); + assert.ok(yaml.includes("dpa-signed: true")); + assert.ok(yaml.includes("sccs-required: false")); + assert.ok(yaml.includes("contact:")); + assert.ok(yaml.includes("source: library-trace")); + }); + + test("omits undefined fields from entry", () => { + const entries = [{ package: "stripe", source: "library-trace" }]; + const yaml = renderSubProcessorsYaml(entries); + assert.ok( + !yaml.includes("data-sent:"), + "should not render missing data-sent", + ); + assert.ok(!yaml.includes("region:"), "should not render missing region"); + assert.ok( + !yaml.includes("dpa-signed:"), + "should not render missing dpa-signed", + ); + }); + + test("output is deterministic across multiple calls", () => { + const entries = [ + { package: "stripe", source: "library-trace", decision: "approved" }, + ]; + assert.equal( + renderSubProcessorsYaml(entries), + renderSubProcessorsYaml(entries), + ); + }); + + test("package field is the first field in each list item (starts with '- package:')", () => { + const entries = [{ package: "stripe", source: "library-trace" }]; + const yaml = renderSubProcessorsYaml(entries); + assert.ok( + yaml.includes(" - package: stripe"), + "entry should start with ' - package:'", + ); + }); + + test("OUTPUT_PATH is compliance/sub-processors.yml", () => { + assert.equal(OUTPUT_PATH, "compliance/sub-processors.yml"); + }); +}); + +describe("--check mode (integration)", () => { + test("passes when committed file matches generated output", () => { + const entries = [ + { package: "stripe", source: "library-trace", decision: "approved" }, + ]; + const yaml = renderSubProcessorsYaml(entries); + const diff = unifiedDiff(yaml, yaml, OUTPUT_PATH); + assert.equal(diff, null, "no diff expected when files match"); + }); + + test("fails with readable diff when committed file is stale", () => { + const entries = [ + { package: "stripe", source: "library-trace", decision: "approved" }, + ]; + const yaml = renderSubProcessorsYaml(entries); + const stale = "# stale content\nsub-processors: []\n"; + const diff = unifiedDiff(stale, yaml, OUTPUT_PATH); + assert.ok(diff !== null, "diff expected when file is stale"); + assert.ok( + diff.includes(`--- ${OUTPUT_PATH}`), + "diff should include filename header", + ); + assert.ok( + diff.includes("- # stale content"), + "diff should show removed line", + ); + assert.ok(diff.includes("Line"), "diff should include line numbers"); + }); +});