From 6fbfbf9af4542f1b04f458d7e98a2dbbce4d75ae Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Sat, 24 Oct 2026 16:39:53 -0500 Subject: [PATCH] Fix attestation occurrence matching and exception expiry reminders --- package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/attestation-integrity.js | 28 ++++ packages/filegrc/src/obligations.js | 38 ++++-- packages/filegrc/src/validate.js | 38 +----- packages/filegrc/src/workflow.js | 9 +- .../test/attestation-occurrence.test.js | 120 ++++++++++++++++++ .../test/fixtures/attestation-occurrence.js | 26 ++++ packages/filegrc/test/workflow.test.js | 41 ++++++ 13 files changed, 261 insertions(+), 59 deletions(-) create mode 100644 packages/filegrc/src/attestation-integrity.js create mode 100644 packages/filegrc/test/attestation-occurrence.test.js create mode 100644 packages/filegrc/test/fixtures/attestation-occurrence.js diff --git a/package-lock.json b/package-lock.json index b34fb55..bfb3ee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.0", + "version": "0.20.1", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.0", + "version": "0.20.1", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.0", + "version": "0.20.1", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 8c02402..6cad774 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.0", + "version": "0.20.1", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 91baf58..00008e6 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.0", + "version": "0.20.1", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index d8a32ff..54a7b68 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.20.0", + version: "0.20.1", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.0", + version: "0.20.1", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 92fbbc0..9b0019b 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.20.0", + "version": "0.20.1", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index d956a00..84704aa 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.0", + "version": "0.20.1", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/attestation-integrity.js b/packages/filegrc/src/attestation-integrity.js new file mode 100644 index 0000000..cb6cff1 --- /dev/null +++ b/packages/filegrc/src/attestation-integrity.js @@ -0,0 +1,28 @@ +import { modelSupports } from "../model/index.js"; +import { markdownEntries } from "./resource-markdown.js"; +import { revisionDigest } from "./revisions.js"; + +// Check historical bindings without comparing them with today's content. The +// filesystem validator supplies existing paths; planners can require only +// mandatory authored content because optional Markdown may not exist. +export function attestationBindingIssue(record, model, byId = new Map(), subjectPaths = null) { + if (record.type !== "attestation" || record.status !== "completed" + || (record.attestationMethod !== "git-approval" && !modelSupports(model, "complete-operating-baseline"))) return null; + const filesystemPaths = subjectPaths !== null; + subjectPaths ||= new Map((record.subjectResourceIds || []).map((id) => [id, + byId.get(id) ? markdownEntries(model, byId.get(id)).map(({ path }) => path) : []])); + const expectedPaths = new Set([...subjectPaths.values()].flat()); + if (!expectedPaths.size) return "A git-approval Attestation must reference authored Policy, Document, or Training content."; + const actualPaths = Object.keys(record.contentRevisions || {}); + if (!actualPaths.length) return "A git-approval Attestation must bind at least one subject Markdown file."; + const unboundSubjects = [...subjectPaths].filter(([id, paths]) => { + const requiresContent = filesystemPaths || byId.get(id) + && markdownEntries(model, byId.get(id)).some(({ required }) => required); + return requiresContent && paths.length && !paths.some((path) => actualPaths.includes(path)); + }).map(([id]) => id); + if (unboundSubjects.length) return `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`; + if (actualPaths.some((path) => !expectedPaths.has(path) || !revisionDigest("content", record.contentRevisions[path]))) { + return "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."; + } + return null; +} diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index c69c55d..b1942dd 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { attestationBindingIssue } from "./attestation-integrity.js"; import { preparationEvidenceIssues } from "./preparation-evidence.js"; import { assertHistoricalRecordValid } from "./validate.js"; import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; @@ -1260,6 +1261,11 @@ function occurrenceProofDate(occurrence, asOf, timezone = "UTC") { function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC") { const assessmentDate = asOf || currentCalendarDate(timezone); + if (record.type === "attestation") { + if (record.status !== "completed" || !record.completedOn || record.completedOn > assessmentDate + || attestationBindingIssue(record, model, byId)) return false; + try { assertHistoricalRecordValid(record, model, resourceDataPath(model, record)); } catch { return false; } + } if (model && modelSupports(model, "operating-preparation") && model.operatingPreparation?.completionRecords?.[record.type]) { const spec = model.operatingPreparation.completionRecords[record.type]; if (byId && (record.type === "evidence" @@ -1284,6 +1290,16 @@ function completionPassesActivity(record, activity, model, byId, asOf, timezone } export function completionMatchesMember(record, field, resourceId, model, byId, obligation) { + if (record?.type === "attestation") { + const { expectedPeople, requiredSubjects } = attestationCompletionScope(obligation, byId); + if (expectedPeople.length && !expectedPeople.includes(record.personId)) return false; + const subjects = new Set(record.subjectResourceIds || []); + if (!requiredSubjects.length || requiredSubjects.some((id) => !subjects.has(id))) return false; + const memberType = byId?.get(resourceId)?.type; + if (memberType === "person") return record.personId === resourceId; + return Boolean(model?.resources.attestation?.fields.subjectResourceIds?.relation?.includes(memberType) + && subjects.has(resourceId)); + } if (byId?.get(resourceId)?.type === "workspace" && !obligation?.scopeResourceIds?.length && record.obligationId === obligation?.id) return true; const includes = (value) => Array.isArray(value) ? value.includes(resourceId) : value === resourceId; @@ -1400,25 +1416,25 @@ function assertExpectedCompletionType(obligation, record, model) { } } -function assertAttestationCompletionScope(obligation, record, resources, event = null) { - if (record?.type !== "attestation") return; - const byId = resourceIndex(resources).byId; +function attestationCompletionScope(obligation = {}, byId = new Map(), event = null) { const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person"); const expectedPeople = eventPeople.length ? eventPeople : (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person"); + const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])] + .filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)); + return { expectedPeople, requiredSubjects: primarySubjects.length ? primarySubjects : obligation.policyIds || [] }; +} + +function assertAttestationCompletionScope(obligation, record, resources, event = null) { + if (record?.type !== "attestation") return; + const { expectedPeople, requiredSubjects } = attestationCompletionScope(obligation, resourceIndex(resources).byId, event); if (expectedPeople.length && !expectedPeople.includes(record.personId)) { throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`); } - const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])] - .filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)); - const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []); const actualSubjects = new Set(record.subjectResourceIds || []); - if ( - actualSubjects.size !== allowedSubjects.size - || [...actualSubjects].some((id) => !allowedSubjects.has(id)) - ) { - throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`); + if (!requiredSubjects.length || requiredSubjects.some((id) => !actualSubjects.has(id))) { + throw new Error(`Attestation subjects must include every authored subject in scope for Obligation "${obligation.id}".`); } } diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index da2e362..30d4ff2 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,4 @@ +import { attestationBindingIssue } from "./attestation-integrity.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { retentionApplicationIssues } from "./retention-applications.js"; import { nonmaterialAmendmentIssues } from "./nonmaterial-amendments.js"; @@ -2249,7 +2250,6 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn || (record.attestationMethod !== "git-approval" && !modelSupports(model, "complete-operating-baseline")) || !record.contentRevisions ) return; - const expectedPaths = new Set(); const subjectPaths = new Map(); for (const id of record.subjectResourceIds || []) { const subject = byId.get(id); @@ -2263,41 +2263,9 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn } } subjectPaths.set(id, paths); - for (const item of paths) expectedPaths.add(item); - } - const actualPaths = Object.keys(record.contentRevisions); - if (!expectedPaths.size) { - diagnostics.push(error( - "invalid-attestation-binding", - path, - "A git-approval Attestation must reference authored Policy, Document, or Training content." - )); - return; - } - if (!actualPaths.length) { - diagnostics.push(error( - "invalid-attestation-binding", - path, - "A git-approval Attestation must bind at least one subject Markdown file." - )); - return; - } - const invalid = actualPaths.filter((item) => ( - !expectedPaths.has(item) - || !revisionDigest("content", record.contentRevisions[item]) - )); - const unboundSubjects = [...subjectPaths].filter(([, paths]) => ( - paths.length && !paths.some((item) => actualPaths.includes(item)) - )).map(([id]) => id); - if (invalid.length || unboundSubjects.length) { - diagnostics.push(error( - "invalid-attestation-binding", - path, - unboundSubjects.length - ? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.` - : "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths." - )); } + const issue = attestationBindingIssue(record, model, byId, subjectPaths); + if (issue) diagnostics.push(error("invalid-attestation-binding", path, issue)); } function validateReportingRouteBinding(record, loaded, path, diagnostics) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 60cb982..c60bcb8 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1583,6 +1583,7 @@ function activeWork(item) { } function firstDate(record) { + if (record.type === "exception" && typeof record.approval?.expiresOn === "string") return record.approval.expiresOn; if (record.completionWindow?.dueAt) return record.completionWindow.dueAt; if (record.completionWindow?.dueOn) return record.completionWindow.dueOn; for (const field of DATE_FIELDS) { @@ -1619,8 +1620,9 @@ function sourceCompletionProfile(record) { } function sourceNextAction(record, due, asOf, records) { - const eventType = due <= asOf ? { - exception: "exception-expired", + const eventType = record.type === "exception" + ? (record.status === "approved" && due < asOf ? "exception-expired" : null) + : due <= asOf ? { "service-account": "service-account-expired", vulnerability: "vulnerability-overdue" }[record.type] : null; @@ -1650,7 +1652,8 @@ function firstOwners(record) { } function sourceWorkState(record, due, asOf) { - if (TERMINAL_STATUSES.has(record.status)) return "complete"; + if (record.type === "exception" && ["closed", "revoked"].includes(record.status)) return "complete"; + if (!(record.type === "exception" && record.status === "approved") && TERMINAL_STATUSES.has(record.status)) return "complete"; if (["canceled", "superseded"].includes(record.status)) return record.status; if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) { return "proposed"; diff --git a/packages/filegrc/test/attestation-occurrence.test.js b/packages/filegrc/test/attestation-occurrence.test.js new file mode 100644 index 0000000..62ccf7c --- /dev/null +++ b/packages/filegrc/test/attestation-occurrence.test.js @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { trainingFixture } from "./fixtures/attestation-occurrence.js"; +import { writeJson } from "./helpers.js"; +import { contentRevision } from "../src/files.js"; +import { completeObligationOccurrence, loadWorkspace, planObligations, scaffoldObligationOccurrence, saveObligationOccurrence, validateWorkspace } from "../src/index.js"; +import { assessCompletionForObligation, completionMatchesMember } from "../src/obligations.js"; +import { loadModel } from "../model/index.js"; + +for (const scope of [["training-example"], ["person-example"], ["person-example", "training-example"]]) { + test(`existing combined acknowledgement reconciles exact ${scope.join(" and ")} members`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-training-occurrence-")); + t.after(() => rm(root, { recursive: true, force: true })); + const f = await trainingFixture(root, scope); + const before = await readFile(f.attestationEntry.path, "utf8"); + const scaffold = await scaffoldObligationOccurrence(root, { obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.equal(scaffold.membershipFinal, true); + assert.deepEqual(scaffold.record.members.map(({ resourceId }) => resourceId).sort(), [...scope].sort()); + for (const member of scaffold.record.members) { + assert.deepEqual(member.completionResourceIds, [f.attestation.id]); + assert.equal(member.result, "passed"); + } + await saveObligationOccurrence(root, { record: { ...scaffold.record, status: "reconciled", completedCount: scope.length, + conclusion: "complete", reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T18:00:00Z" } }); + assert.equal(await readFile(f.attestationEntry.path, "utf8"), before); + const loaded = await loadWorkspace(root); + const item = planObligations(loaded.resources, { model: loaded.model, asOf: "2026-07-07", through: "2026-07-07", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.equal(item.status, "complete"); + const validation = await validateWorkspace(root); + assert.deepEqual(validation.diagnostics.filter(({ code }) => /occurrence|member|population|attestation/.test(code)), []); + }); +} + +test("attestation membership uses typed authored subjects and the actual person, including combined acknowledgements", () => { + const model = loadModel("13"); + const byId = new Map(["person", "training", "policy", "document", "action-item", "system"].map((type) => [`${type}-example`, { id: `${type}-example`, type }])); + const obligation = { scopeResourceIds: ["person-example", "training-example"] }; + const record = { type: "attestation", personId: "person-example", subjectResourceIds: ["policy-example", "training-example", "document-example", "action-item-example"] }; + for (const type of ["person", "training", "policy", "document", "action-item"]) { + assert.equal(completionMatchesMember(record, null, `${type}-example`, model, byId, obligation), true, type); + } + assert.equal(completionMatchesMember(record, null, "system-example", model, byId, obligation), false); + assert.equal(completionMatchesMember({ ...record, personId: "person-other" }, "subjectResourceIds", "training-example", model, byId, obligation), false); + assert.equal(completionMatchesMember({ ...record, subjectResourceIds: ["policy-example"] }, null, "person-example", model, byId, obligation), false); +}); + +test("atomic completion reuses an existing combined acknowledgement without duplicating or changing it", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-training-reuse-")); + t.after(() => rm(root, { recursive: true, force: true })); + const f = await trainingFixture(root); + const before = await readFile(f.attestationEntry.path, "utf8"); + const result = await completeObligationOccurrence(root, { obligationId: f.obligation.id, record: f.attestation, + expectedRevision: contentRevision(await readFile(f.obligationEntry.path, "utf8")) }); + assert.equal(result.linked.members[0].result, "passed"); + assert.deepEqual(result.linked.members[0].completionResourceIds, [f.attestation.id]); + assert.equal(await readFile(f.attestationEntry.path, "utf8"), before); + assert.equal((await loadWorkspace(root)).resources.filter(({ type }) => type === "attestation").length, 1); +}); + +test("attestations cannot pass with another person or subject, incomplete status, invalid proof or an ineligible date", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-training-rejection-")); + t.after(() => rm(root, { recursive: true, force: true })); + const f = await trainingFixture(root, ["person-example", "training-example"]); + const loaded = await loadWorkspace(root); + const window = { dueWindowStart: "2026-07-05", dueWindowEnd: "2026-07-21" }; + const variants = [ + { ...f.attestation, personId: "person-independent-approver-example" }, + { ...f.attestation, subjectResourceIds: ["policy-example"], contentRevisions: { "policies/policy-example.md": f.attestation.contentRevisions["policies/policy-example.md"] } }, + { ...f.attestation, completedOn: "2026-07-04" }, + { ...f.attestation, completedOn: "2026-07-08" }, + { ...f.attestation, contentRevisions: undefined }, + { ...f.attestation, contentRevisions: { "policies/policy-example.md": f.attestation.contentRevisions["policies/policy-example.md"] } }, + { ...f.attestation, contentRevisions: { ...f.attestation.contentRevisions, "training/training-example.md": "invalid" } } + ]; + for (const status of ["pending", "waived"]) { + const record = { ...f.attestation, status }; + for (const field of ["completedOn", "attestationMethod", "contentRevisions"]) delete record[field]; + if (status === "waived") Object.assign(record, { waivedByIds: ["person-example"], waiverReason: "Not a training completion." }); + variants.push(record); + } + for (const record of variants) { + assert.equal(assessCompletionForObligation(record, f.obligation, loaded, { memberIds: ["person-example", "training-example"], window, asOf: "2026-07-07" }).complete, false, JSON.stringify(record)); + await writeJson(f.attestationEntry.path, record); + const scaffold = await scaffoldObligationOccurrence(root, { obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.ok(scaffold.record.members.every(({ result }) => result !== "passed")); + const injected = { ...scaffold.record, status: "reconciled", completedCount: 2, conclusion: "complete", reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T18:00:00Z", + members: scaffold.record.members.map((member) => ({ ...member, result: "passed", completionResourceIds: [record.id] })) }; + await assert.rejects(saveObligationOccurrence(root, { record: injected }), /completion|subject|person|contentRevisions|completedOn/i); + assert.equal((await loadWorkspace(root)).resources.some(({ type }) => type === "obligation-occurrence"), false); + } +}); + +test("authored members cannot bypass another required template subject", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-training-template-")); + t.after(() => rm(root, { recursive: true, force: true })); + const f = await trainingFixture(root); + const loaded = await loadWorkspace(root); + const obligation = { ...f.obligation, templateResourceId: "document-example" }; + await writeJson(f.obligationEntry.path, obligation); + const scaffold = await scaffoldObligationOccurrence(root, { obligationId: obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.equal(scaffold.record.members[0].result, "pending"); + assert.equal(assessCompletionForObligation(f.attestation, obligation, loaded, { memberIds: ["training-example"], window: { dueWindowStart: "2026-07-05", dueWindowEnd: "2026-07-21" }, asOf: "2026-07-07" }).complete, false); +}); + +test("combined acknowledgements preserve subjects without optional Markdown", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-training-optional-content-")); + t.after(() => rm(root, { recursive: true, force: true })); + const f = await trainingFixture(root); + await rm(join(root, "data/action-items/action-item-example.md"), { force: true }); + await writeJson(f.attestationEntry.path, { ...f.attestation, subjectResourceIds: [...f.attestation.subjectResourceIds, "action-item-example"] }); + const validation = await validateWorkspace(root); + assert.deepEqual(validation.diagnostics.filter(({ code }) => /attestation/.test(code)), []); + const scaffold = await scaffoldObligationOccurrence(root, { obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.equal(scaffold.record.members[0].result, "passed"); + await saveObligationOccurrence(root, { record: { ...scaffold.record, status: "reconciled", completedCount: 1, + conclusion: "complete", reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T18:00:00Z" } }); +}); diff --git a/packages/filegrc/test/fixtures/attestation-occurrence.js b/packages/filegrc/test/fixtures/attestation-occurrence.js new file mode 100644 index 0000000..4616867 --- /dev/null +++ b/packages/filegrc/test/fixtures/attestation-occurrence.js @@ -0,0 +1,26 @@ +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { makeComprehensiveWorkspace } from "../fixtures.js"; +import { writeJson } from "../helpers.js"; +import { contentRevision } from "../../src/files.js"; +import { loadWorkspace } from "../../src/index.js"; + +export async function trainingFixture(root, scopeResourceIds = ["training-example"]) { + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const obligationEntry = loaded.entries.find(({ record }) => record.type === "obligation"); + const ruleEntry = loaded.entries.find(({ record }) => record.type === "obligation-rule"); + const attestationEntry = loaded.entries.find(({ record }) => record.type === "attestation"); + const obligation = { ...obligationEntry.record, activityType: "training", scopeResourceIds, templateResourceId: "training-example" }; + const rule = { ...ruleEntry.record, recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-01-01" }, + window: { precision: "date", startsAfter: 0, dueAfter: 20 }, effectiveAt: "2026-07-05T00:00:00-05:00" }; + delete rule.selector; + await writeJson(obligationEntry.path, obligation); + await writeJson(ruleEntry.path, rule); + for (const entry of loaded.entries.filter(({ record }) => record.type === "obligation-occurrence")) await rm(entry.path); + const attestation = { ...attestationEntry.record, completedOn: "2026-07-07", subjectResourceIds: ["policy-example", "training-example"], + contentRevisions: { ...attestationEntry.record.contentRevisions, + "training/training-example.md": contentRevision(await readFile(join(root, "data/training/training-example.md"), "utf8")) } }; + await writeJson(attestationEntry.path, attestation); + return { root, obligation, rule, attestation, attestationEntry, obligationEntry }; +} diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index 60fde80..1f58889 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -788,3 +788,44 @@ test("Program workflow excludes unrelated source deadlines and does not recreate assert.equal(workflow.workItems.find(({ source }) => source.id === "evidence-audit-selected")?.state, "overdue"); assert.equal(workflow.workItems.some(({ source }) => source.id === "evidence-audit-unrelated"), false); }); + +test("approved Exception expiry remains actionable through its lifecycle and Program scope", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-exception-deadline-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const exception = loaded.resources.find(({ type }) => type === "exception"); + const system = loaded.resources.find(({ type }) => type === "system"); + const record = { ...exception, status: "approved", scopeResourceIds: [system.id], + approval: { approvedByIds: ["person-example"], approvedOn: "2026-07-01", expiresOn: "2026-07-10" } }; + const path = join(root, "data", "exceptions", `${record.id}.json`); + await writeJson(path, record); + await writeJson(join(root, "data", "systems", "system-unrelated.json"), { ...system, id: "system-unrelated" }); + await writeJson(join(root, "data", "exceptions", "exception-unrelated.json"), { ...record, id: "exception-unrelated", scopeResourceIds: ["system-unrelated"] }); + const assess = (asOf, includeComplete = false) => assessWorkflow(root, { asOf, includeComplete, evaluatedAt: `${asOf}T12:00:00Z` }); + for (const [asOf, state] of [["2026-07-09", "scheduled"], ["2026-07-10", "due"], ["2026-07-11", "overdue"]]) { + const workflow = await assess(asOf); + const items = workflow.workItems.filter(({ source }) => source.id === record.id); + assert.equal(items.length, 1); + const item = items[0]; + assert.equal(item.state, state); + assert.equal(item.dueOn, record.approval.expiresOn); + assert.equal(item.kind, "source-deadline"); + assert.deepEqual(item.ownerIds, record.ownerIds); + assert.deepEqual(item.scope, record.scopeResourceIds); + assert.equal(item.requiredCompletionProfile, "exception-expiry-and-compensating-control-review"); + assert.equal(item.nextAction.command.includes("trigger exception-expired"), state === "overdue"); + assert.equal(workflow.workItems.some(({ source }) => source.id === "exception-unrelated"), false); + } + for (const status of ["closed", "revoked"]) { + await writeJson(path, { ...record, status, resolution: { resolvedByIds: ["person-example"], resolvedOn: "2026-07-09", rationale: "Departure ended." } }); + assert.equal((await assess("2026-07-11")).workItems.some(({ source }) => source.id === record.id), false); + const completed = (await assess("2026-07-11", true)).workItems.find(({ source }) => source.id === record.id); + assert.equal(completed.state, "complete"); + assert.equal(completed.nextAction.command.includes("trigger exception-expired"), false); + } + const requested = { ...record, status: "requested" }; + delete requested.approval; + await writeJson(path, requested); + assert.equal((await assess("2026-07-11")).workItems.some(({ source }) => source.id === record.id), false); +});