From f817b1492824f36a9ed7e2e5843437aeee7c74b7 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Sat, 24 Oct 2026 20:32:20 -0500 Subject: [PATCH] Allow additive evidence verification and release 0.20.3 --- 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/evidence-observation.js | 68 ++++++++ packages/filegrc/src/files.js | 20 ++- .../filegrc/src/nonmaterial-amendments.js | 16 +- packages/filegrc/src/server.js | 1 + packages/filegrc/src/web.js | 10 +- .../filegrc/src/workflow-history-integrity.js | 6 +- .../test/evidence-verification.test.js | 147 ++++++++++++++++++ packages/filegrc/test/render.test.js | 21 +++ 14 files changed, 272 insertions(+), 37 deletions(-) create mode 100644 packages/filegrc/src/evidence-observation.js create mode 100644 packages/filegrc/test/evidence-verification.test.js diff --git a/package-lock.json b/package-lock.json index 8be06f8..ceb419b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.2", + "version": "0.20.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.2", + "version": "0.20.3", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.2", + "version": "0.20.3", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.2", + "version": "0.20.3", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 57cbe54..f1c324a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.2", + "version": "0.20.3", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index faf7e3b..e014704 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.2", + "version": "0.20.3", "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 954d557..1c95d81 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.2", + version: "0.20.3", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.2", + version: "0.20.3", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 8150ee1..4657982 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.2", + "version": "0.20.3", "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 8565c9d..0f3d9b6 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.2", + "version": "0.20.3", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/evidence-observation.js b/packages/filegrc/src/evidence-observation.js new file mode 100644 index 0000000..24f47ca --- /dev/null +++ b/packages/filegrc/src/evidence-observation.js @@ -0,0 +1,68 @@ +import { getDataRecordHistoryIndex, isDataHistoryAncestor } from "./git.js"; +import { reviewHistoryContext, reviewHistoricalWorkspace } from "./historical-workspace.js"; +import { currentCalendarDate, isRfc3339Timestamp } from "./time.js"; +import { personWasActiveOn } from "./soc2.js"; + +const VERIFICATION_FIELDS = new Set(["status", "verifiedOn", "verifiedAt", "verifierIds"]); +const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); +} + +// Finalized decisions bind the observation. A later first independent +// verification may add only its own metadata, which then becomes immutable. +export function evidenceObservationMatches(original, current, loaded = null, reliedOnCommit = null) { + if (!original || !current) return false; + const index = loaded ? getDataRecordHistoryIndex(loaded.root) : null; + if (loaded && original.status === "collected" && !index.available) return false; + let verificationCommit = null, verificationTimestamp = null; + if (original.status === "collected" && loaded && reliedOnCommit) { + for (const version of [...(index.historiesById.get(original.id) || [])].reverse()) { + if (!isDataHistoryAncestor(loaded, reliedOnCommit, version.commit)) continue; + const record = index.recordsByCommit.get(version.commit)?.get(original.id)?.record; + if (record?.status === "verified") { + if (!same(record, current)) return false; + verificationCommit = version.commit; + verificationTimestamp = version.timestamp; + break; + } + } + } + if (same(original, current)) return true; + if (original.type !== "evidence" || current.type !== "evidence" + || original.status !== "collected" || current.status !== "verified") return false; + if (![...new Set([...Object.keys(original), ...Object.keys(current)])] + .every((key) => VERIFICATION_FIELDS.has(key) || same(original[key], current[key]))) return false; + // Existing metadata is never replaced, even on a collected observation. + for (const key of ["verifiedOn", "verifiedAt", "verifierIds"]) { + const prior = original[key]; + const empty = prior === undefined || prior === null || (key === "verifierIds" && Array.isArray(prior) && !prior.length); + if (!empty && !same(prior, current[key])) return false; + } + if (!loaded) return true; + const context = verificationCommit + ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), verificationCommit) : loaded; + if (!context) return false; + const timezone = context.workspace.timezone; + // Git records whole seconds; allow the subsecond part of that same instant. + const now = verificationTimestamp ? new Date(new Date(verificationTimestamp).getTime() + 999) : new Date(); + const today = currentCalendarDate(timezone, now); + const verifiers = current.verifierIds; + if (!/^\d{4}-\d{2}-\d{2}$/.test(current.verifiedOn || "") + || current.verifiedOn < original.collectedOn || current.verifiedOn > today + || !Array.isArray(verifiers) || !verifiers.length + || verifiers.some((id) => (original.collectorIds || []).includes(id) + || !personWasActiveOn(context.resources.find((record) => record.id === id), current.verifiedOn))) return false; + if (current.verifiedAt && (!isRfc3339Timestamp(current.verifiedAt) + || new Date(current.verifiedAt) > now + || currentCalendarDate(timezone, new Date(current.verifiedAt)) !== current.verifiedOn + || (original.collectedAt && new Date(current.verifiedAt) < new Date(original.collectedAt)))) return false; + if (reliedOnCommit) { + const reliedOn = index.changesByCommit.get(reliedOnCommit)?.[0]?.summary.timestamp; + if (!reliedOn || current.verifiedOn < currentCalendarDate(timezone, new Date(reliedOn)) + || (current.verifiedAt && new Date(current.verifiedAt) < new Date(reliedOn))) return false; + } + return true; +} diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index c82e154..91e0817 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -1,3 +1,4 @@ +import { evidenceObservationMatches } from "./evidence-observation.js"; import { workflowProofIds } from "./workflow-proof.js"; import { randomUUID } from "node:crypto"; import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; @@ -386,7 +387,7 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio requireExpectedRevisions: hasContentUpdate }); if (hasContentUpdate) preparedContentIds.add(record.id); - if (recordContentWrites.length) assertWorkflowContentMutable(loaded, existing.record); + if (recordContentWrites.some(({ source, previous }) => source !== previous)) assertWorkflowContentMutable(loaded, existing.record); contentWrites.push(...recordContentWrites); const nextRecord = hasContentUpdate ? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record) @@ -654,7 +655,7 @@ async function updateResourceUnlocked(input, type, id, record, options) { }); const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record; assertImmutableWorkflowRecord(existing, record, options, loaded); - if (contentWrites.length) assertWorkflowContentMutable(loaded, existing); + if (contentWrites.some(({ source, previous }) => source !== previous)) assertWorkflowContentMutable(loaded, existing); const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing); assertGovernedContentLifecycleMutation(existing, nextRecord, loaded.model, null); try { @@ -761,9 +762,10 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu throw new Error(`Obligation occurrence "${existing.id}" is workflow-managed. Scaffold and save its reconciliation instead of editing it directly.`); } const finalizedOccurrence = finalizedOccurrenceUsingProof(loaded, existing.id); - if (finalizedOccurrence && !preservesExisting()) { - throw new Error( - `Completion record "${existing.id}" is immutable because finalized occurrence "${finalizedOccurrence.id}" relies on it. ` + if (finalizedOccurrence && !preservesExisting() + && !evidenceObservationMatches(existing, next, loaded)) { + throw finalizedProofError( + `${existing.type === "evidence" ? "Proof" : "Completion"} record "${existing.id}" is immutable because finalized occurrence "${finalizedOccurrence.id}" relies on it. ` + "Create a corrected completion and superseding occurrence instead." ); } @@ -840,10 +842,14 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu } } +function finalizedProofError(message) { + return Object.assign(new Error(message), { code: "FILEGRC_FINALIZED_PROOF" }); +} + function assertFinalizedOccurrenceProofMutable(loaded, record) { const occurrence = finalizedOccurrenceUsingProof(loaded, record.id); if (occurrence) { - throw new Error( + throw finalizedProofError( `Proof record "${record.id}" is immutable because finalized occurrence "${occurrence.id}" relies on it. ` + "Create corrected proof and a superseding occurrence instead." ); @@ -873,6 +879,8 @@ function finalizedOccurrenceUsingProof(loaded, targetId) { (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status)) || (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status)) || (record.type === "attestation" && record.status === "completed") + || record.type === "nonmaterial-amendment" + || (record.type === "exception" && ["approved", "revoked", "closed"].includes(record.status)) ))) { if (workflowProofIds(owner, (id) => byId.get(id)).has(targetId)) return owner; } diff --git a/packages/filegrc/src/nonmaterial-amendments.js b/packages/filegrc/src/nonmaterial-amendments.js index 8307025..e378027 100644 --- a/packages/filegrc/src/nonmaterial-amendments.js +++ b/packages/filegrc/src/nonmaterial-amendments.js @@ -1,3 +1,4 @@ +import { evidenceObservationMatches as amendmentEvidenceObservationMatches } from "./evidence-observation.js"; import { readFileSync } from "node:fs"; import { validateWorkspace } from "./validate.js"; import { join } from "node:path"; @@ -137,20 +138,7 @@ export function nonmaterialAmendmentIssues(loaded, amendment) { return issues; } -// Independent verification can be added later without changing the observation -// the owner relied on. Captures, collectors, dates and conclusions stay fixed. -export function amendmentEvidenceObservationMatches(original, current, loaded = null, reliedOnCommit = null) { - if (!original || !current) return false; - if (original.status === "collected" && loaded && reliedOnCommit) { - const history = [...(getDataRecordHistoryIndex(loaded.root).historiesById.get(original.id) || [])].reverse(); - const firstVerified = history.filter(({ commit }) => isDataHistoryAncestor(loaded, reliedOnCommit, commit)) - .map(({ commit }) => historicalRecord(loaded, original.id, commit)).find((record) => record?.status === "verified"); - if (firstVerified && (!amendmentEvidenceObservationMatches(original, firstVerified) || JSON.stringify(firstVerified) !== JSON.stringify(current))) return false; - } - const verification = original.status === "collected" && current.status === "verified" - ? new Set(["status", "verifiedOn", "verifiedAt", "verifierIds"]) : new Set(); - return [...new Set([...Object.keys(original), ...Object.keys(current)])].every((key) => verification.has(key) || JSON.stringify(original[key]) === JSON.stringify(current[key])); -} +export { evidenceObservationMatches as amendmentEvidenceObservationMatches } from "./evidence-observation.js"; function fixedSupport(loaded, evidence, amendment, date, commit) { return evidence?.type === "evidence" && ["collected", "verified"].includes(evidence.status) diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 92930a9..510dce7 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1421,6 +1421,7 @@ function urlHost(host) { } function statusFor(error) { + if (error?.code === "FILEGRC_FINALIZED_PROOF") return 400; if (error?.code === "FILEGRC_RETENTION_APPLICATION") return 400; if (error?.code === "FILEGRC_ACTIVATION_NOT_READY") return 400; if (error?.code === "FILEGRC_STATE_EXPIRED") return 409; diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 217b274..c7247bb 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -6019,10 +6019,7 @@ function readGuidedRecord(dialog, base, fields) { const record = structuredClone(base); for (const group of dialog.querySelectorAll("[data-field-group]")) { const name = group.dataset.fieldGroup; - if (group.hidden) { - delete record[name]; - continue; - } + if (group.hidden) continue; const kind = group.dataset.kind; let value; if (kind === "relation-array" || kind === "choice-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value); @@ -6050,6 +6047,11 @@ function readGuidedRecord(dialog, base, fields) { if (empty && group.dataset.required !== "true") delete record[name]; else record[name] = value; } + // Visibility controls the form, while allowedWhen controls stored facts. + // Keep valid existing optional values when their editor is hidden. + for (const [name, field] of Object.entries(fields)) { + if (field.allowedWhen && !conditionMatchesValues(field.allowedWhen, (key) => record[key])) delete record[name]; + } return record; } diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 13ce352..1a78a84 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -1,5 +1,5 @@ import { workflowProofIds } from "./workflow-proof.js"; -import { amendmentEvidenceObservationMatches } from "./nonmaterial-amendments.js"; +import { evidenceObservationMatches } from "./evidence-observation.js"; import { readFile } from "node:fs/promises"; import { getDataRecordHistoryIndex, @@ -283,8 +283,8 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { diagnostics.push(integrityError(ownerEntry, `Proof record "${id}" must exist in the commit where ${owner.type} "${owner.id}" was first finalized.`)); continue; } - if (!(owner.type === "nonmaterial-amendment" && historical.record.type === "evidence" - ? amendmentEvidenceObservationMatches(historical.record, currentEntry.record, loaded, finalized.commit) + if (!(historical.record.type === "evidence" + ? evidenceObservationMatches(historical.record, currentEntry.record, loaded, finalized.commit) : sameJson(currentEntry.record, historical.record))) { diagnostics.push(integrityError(currentEntry, `Proof record "${id}" differs from the version used when ${owner.type} "${owner.id}" was first finalized. Create corrected proof and a superseding record.`)); continue; diff --git a/packages/filegrc/test/evidence-verification.test.js b/packages/filegrc/test/evidence-verification.test.js new file mode 100644 index 0000000..83819b0 --- /dev/null +++ b/packages/filegrc/test/evidence-verification.test.js @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { initializeGitWorkspace, commitWorkspaceFiles, writeJson } from "./helpers.js"; +import { loadWorkspace, updateResource, validateWorkspace } from "../src/index.js"; +import { addEvidenceAttachment, removeEvidenceAttachment, updateContent } from "../src/files.js"; +import { validateWorkflowHistoryIntegrity } from "../src/workflow-history-integrity.js"; +import { evidenceObservationMatches } from "../src/evidence-observation.js"; +import { workflowProofIds } from "../src/workflow-proof.js"; +import { currentCalendarDate } from "../src/time.js"; + +async function fixture(t, { timezone, emptyVerification = false } = {}) { + const root = await mkdtemp(join(tmpdir(), "filegrc-evidence-verification-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + if (timezone) { + loaded.workspace.timezone = timezone; + await writeJson(join(root, "data/workspace.json"), loaded.workspace); + } + const evidence = loaded.resources.find(({ type }) => type === "evidence"); + const proof = { ...evidence, id: "evidence-retained-observation", title: "Retained observation", status: "collected", + filePaths: ["evidence/evidence-retained-observation/export.txt"] }; + delete proof.verifierIds; delete proof.verifiedOn; delete proof.verifiedAt; + if (emptyVerification) Object.assign(proof, { verifierIds: [], verifiedOn: null, verifiedAt: null }); + const folder = join(root, "data/evidence", proof.id); + await mkdir(folder, { recursive: true }); + const path = join(folder, "evidence.json"), markdown = join(folder, "evidence.md"); + await writeJson(path, proof); + await writeFile(markdown, "# Observation\n\nThe retained export records the actual observed population.\n"); + await writeFile(join(root, "data", proof.filePaths[0]), "Original fixed observation\n"); + for (const owner of loaded.entries.filter(({ record }) => ["attestation", "exception", "access-grant"].includes(record.type))) { + await writeJson(owner.path, { ...owner.record, evidenceIds: [...(owner.record.evidenceIds || []), proof.id] }); + } + const population = loaded.entries.find(({ record }) => record.type === "audit-population"); + await writeJson(population.path, { ...population.record, status: "reconciled", sourceEvidenceId: proof.id, + sourceComponentId: "component-example", reconciledByIds: ["person-example"], reconciledOn: "2026-07-07", conclusion: "complete" }); + await initializeGitWorkspace(root); + return { root, proof, path, markdown, promote: { ...proof, status: "verified", verifiedOn: currentCalendarDate(loaded.workspace.timezone), verifierIds: ["person-independent-approver-example"] } }; +} +async function integrity(root) { + const diagnostics = []; + await validateWorkflowHistoryIntegrity(await loadWorkspace(root), diagnostics); + return diagnostics; +} + +test("one additive verification preserves collected proof across finalized owners and freezes its metadata", async (t) => { + const f = await fixture(t), loaded = await loadWorkspace(f.root); + for (const type of ["attestation", "exception", "obligation-occurrence", "audit-population"]) { + const owner = loaded.resources.find((record) => record.type === type); + assert.ok(workflowProofIds(owner, (id) => loaded.resources.find((record) => record.id === id)).has(f.proof.id), type); + } + const original = await integrity(f.root); + assert.ok(!original.some(({ message }) => message.includes(f.proof.id)), JSON.stringify(original)); + const validation = await validateWorkspace(f.root); + assert.ok(validation.diagnostics.every(({ code }) => code === "schedule-set-approval-required"), JSON.stringify(validation.diagnostics)); + const content = await readFile(f.markdown, "utf8"); + await updateResource(f.root, "evidence", f.proof.id, f.promote, { content: { content: content } }); + assert.deepEqual(await integrity(f.root), original); + assert.equal(await readFile(f.markdown, "utf8"), content); + await commitWorkspaceFiles(f.root, "Independently verify retained observation"); + assert.deepEqual(await integrity(f.root), original); + const reordered = Object.fromEntries(Object.entries(f.promote).reverse()); + reordered.coverage = Object.fromEntries(Object.entries(reordered.coverage).reverse()); + await updateResource(f.root, "evidence", f.proof.id, reordered); + assert.deepEqual(await integrity(f.root), original); + for (const record of [f.proof, { ...f.promote, verifierIds: ["person-example"] }, { ...f.promote, verifiedOn: "2026-07-08" }, { ...f.promote, status: "withdrawn" }]) { + await assert.rejects(updateResource(f.root, "evidence", f.proof.id, record), /immutable/); + } + await writeJson(f.path, { ...f.promote, verifiedOn: "2026-07-08" }); + const tampered = (await integrity(f.root)).filter(({ message }) => message.includes(f.proof.id)); + for (const type of ["attestation", "exception", "obligation-occurrence", "audit-population"]) assert.ok(tampered.some(({ message }) => message.includes(type)), type); + await writeJson(f.path, f.proof); + assert.ok((await integrity(f.root)).some(({ message }) => message.includes(f.proof.id))); +}); + +test("promotion rejects observation rewrites, conflicted verifiers and backdated or future verification", async (t) => { + const f = await fixture(t); + const variants = [ + { ...f.promote, title: "Different observation" }, { ...f.promote, sourceResourceIds: ["control-example"] }, + { ...f.promote, filePaths: [] }, { ...f.promote, collectorIds: ["person-independent-approver-example"] }, + { ...f.promote, collectedOn: "2026-07-02" }, { ...f.promote, verifierIds: f.proof.collectorIds }, + { ...f.promote, verifierIds: [] }, { ...f.promote, verifierIds: ["appointment-example"] }, + { ...f.promote, verifiedOn: "2026-07-08" }, { ...f.promote, verifiedOn: "2999-01-01" }, + { ...f.promote, verifiedAt: "2999-01-01T12:00:00Z" } + ]; + const original = await readFile(f.path, "utf8"); + for (const record of variants) { + await assert.rejects(updateResource(f.root, "evidence", f.proof.id, record), /immutable|invalid/); + assert.equal(await readFile(f.path, "utf8"), original); + } + await assert.rejects(updateResource(f.root, "evidence", f.proof.id, f.promote, { content: { content: "Changed observation" } }), /immutable/); + await assert.rejects(updateContent(f.root, "evidence/evidence-retained-observation/evidence.md", "Changed observation"), /immutable/); + await assert.rejects(addEvidenceAttachment(f.root, f.proof.id, f.markdown), /immutable/); + await assert.rejects(removeEvidenceAttachment(f.root, f.proof.id, f.proof.filePaths[0]), /immutable/); + const loaded = await loadWorkspace(f.root), commit = loaded.git?.commit; + assert.equal(evidenceObservationMatches(f.proof, f.promote, loaded, commit), true); + await writeFile(join(f.root, "data", f.proof.filePaths[0]), "Replaced observation\n"); + assert.ok((await integrity(f.root)).some(({ code, path }) => code === "rewritten-finalized-attachment" && path.includes(f.proof.id))); +}); + + +test("empty verifier metadata allows promotion and committed verification retains its timezone", async (t) => { + const f = await fixture(t, { timezone: "Etc/GMT+12", emptyVerification: true }); + const loaded = await loadWorkspace(f.root); + const workspaceEntry = loaded.entries.find(({ record }) => record.type === "workspace"); + const workspace = workspaceEntry.record, collected = f.proof; + const verifiedAt = new Date().toISOString(); + const promoted = { ...collected, status: "verified", verifierIds: ["person-independent-approver-example"], + verifiedAt, verifiedOn: currentCalendarDate(workspace.timezone, new Date(verifiedAt)) }; + await updateResource(f.root, "evidence", collected.id, promoted); + await commitWorkspaceFiles(f.root, "Verify observation in its actual timezone"); + await writeJson(workspaceEntry.path, { ...workspace, timezone: "Pacific/Kiritimati" }); + await commitWorkspaceFiles(f.root, "Change current workspace timezone"); + assert.ok(!(await integrity(f.root)).some(({ message }) => message.includes(f.proof.id))); + await writeJson(f.path, { ...promoted, verifiedOn: currentCalendarDate("Pacific/Kiritimati", new Date(verifiedAt)) }); + assert.ok((await integrity(f.root)).some(({ message }) => message.includes(f.proof.id))); +}); + + +test("historical promotion cannot become valid through time passing or later verifier inventory", async (t) => { + const f = await fixture(t); + const commitAt = (at, message) => { + execFileSync("git", ["add", "."], { cwd: f.root }); + execFileSync("git", ["-c", "commit.gpgSign=false", "commit", "--allow-empty", "-m", message], + { cwd: f.root, env: { ...process.env, GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at } }); + }; + execFileSync("git", ["-c", "commit.gpgSign=false", "commit", "--amend", "--no-edit", "--date=2026-07-01T12:00:00Z"], + { cwd: f.root, env: { ...process.env, GIT_COMMITTER_DATE: "2026-07-01T12:00:00Z" } }); + const loaded = await loadWorkspace(f.root), reliedOn = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.root, encoding: "utf8" }).trim(); + const premature = { ...f.promote, verifiedOn: "2026-07-03", verifiedAt: "2026-07-03T12:00:00Z" }; + await writeJson(f.path, premature); + commitAt("2026-07-02T12:00:00Z", "Record invalid future verification"); + assert.equal(evidenceObservationMatches(f.proof, premature, await loadWorkspace(f.root), reliedOn), false); + execFileSync("git", ["reset", "--hard", reliedOn], { cwd: f.root }); + const missingPerson = { ...f.promote, verifiedOn: "2026-07-02", verifierIds: ["person-added-after-verification"] }; + await writeJson(f.path, missingPerson); + commitAt("2026-07-02T12:00:00Z", "Record verification naming absent person"); + const person = loaded.resources.find(({ id }) => id === "person-independent-approver-example"); + await writeJson(join(f.root, "data/people/person-added-after-verification.json"), { ...person, id: "person-added-after-verification" }); + commitAt("2026-07-04T12:00:00Z", "Add later person inventory"); + assert.equal(evidenceObservationMatches(f.proof, missingPerson, await loadWorkspace(f.root), reliedOn), false); +}); diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index b735c5e..b37b7b6 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -1,3 +1,4 @@ +import { loadModel } from "../model/index.js"; import assert from "node:assert/strict"; import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; import { createServer, request } from "node:http"; @@ -2988,3 +2989,23 @@ test("reporting approval accepts minute-precision local inputs and keeps DST che assert.equal(convert("2026-10-12T12:04:32", "America/Chicago"), "2026-10-12T12:04:32-05:00"); assert.throws(() => convert("2026-03-08T02:30", "America/Chicago"), /does not exist/); }); + + +test("guided Evidence verification preserves hidden attachments and clears only model-disallowed values", () => { + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function readGuidedRecord"), APP_SCRIPT.indexOf("function relationCandidates")); + const conditions = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function conditionMatchesValues"), APP_SCRIPT.indexOf("function formatValue")); + const read = vm.runInNewContext(source + conditions + "\nreadGuidedRecord", { structuredClone }); + const fields = loadModel(13).resources.evidence.fields; + const base = { id: "evidence-retained", type: "evidence", status: "collected", sourceKind: "component", + sourceComponentId: "component-source", filePaths: ["evidence/evidence-retained/export.csv"] }; + const group = (name, value, hidden = false) => ({ hidden, dataset: { fieldGroup: name, kind: "enum" }, querySelector: () => ({ value }) }); + const groups = [group("status", "verified"), group("sourceKind", "component"), group("filePaths", "", true), group("sourceComponentId", "", true)]; + const dialog = { querySelectorAll: () => groups }; + const verified = read(dialog, base, fields); + assert.deepEqual(verified, { ...base, status: "verified" }); + groups[1] = group("sourceKind", "external-reference"); + const external = read(dialog, base, fields); + assert.equal(external.sourceKind, "external-reference"); + assert.equal(external.filePaths, undefined); + assert.equal(external.sourceComponentId, undefined); +});