Files
Jyotisha/frontend/tests/personal-report-contract.test.ts
2026-08-15 05:10:37 +08:00

338 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
computeEvidenceHash,
safeParseServerReportDocument,
parseServerReportDocument,
} from "../src/lib/personal-report-contract.server-core.ts";
import {
findBlockedDeterministicClaims,
findChartSetViolations,
findDanglingEvidenceRefs,
findDuplicateEvidenceIds,
CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION,
LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION,
REPORT_DOCUMENT_SCHEMA_VERSION,
findForbiddenContent,
findThemeCoverageViolations,
findUnsupportedDateClaims,
parseReportDocument,
REPORT_DOCUMENT_MAX_BYTES,
safeParseReportDocument,
serializedReportDocumentBytes,
type ReportDocumentV1,
type ReportDocumentV2,
} from "../src/lib/personal-report-contract.ts";
import { ReportDocumentValidationError } from "../src/lib/personal-report-contract.ts";
const fixtureText = readFileSync(
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
"utf8",
);
const fixture: ReportDocumentV1 = JSON.parse(fixtureText);
const clone = () => structuredClone(fixture) as ReportDocumentV1;
const v2FixtureText = readFileSync(
new URL("../../tests/fixtures/personal_report_document.v2.json", import.meta.url),
"utf8",
);
const v2Fixture: ReportDocumentV2 = JSON.parse(v2FixtureText);
const cloneV2 = () => structuredClone(v2Fixture) as ReportDocumentV2;
test("fixture passes isomorphic parse and stays under the 1.5 MiB cap", () => {
const result = safeParseReportDocument(fixture);
assert.equal(result.ok, true);
assert.ok(serializedReportDocumentBytes(fixture) <= REPORT_DOCUMENT_MAX_BYTES);
assert.ok(serializedReportDocumentBytes(fixture) < 100_000);
});
test("isomorphic parse rejects extra keys, missing keys, and bad enums", () => {
const extra = clone();
(extra.subject as Record<string, unknown>).hometown = "上海";
assert.equal(safeParseReportDocument(extra).ok, false);
const missing = clone();
delete (missing as Partial<ReportDocumentV1>).disclaimer;
assert.equal(safeParseReportDocument(missing).ok, false);
const badEnum = clone();
badEnum.subject.birthTimeStatus = "guessed" as ReportDocumentV1["subject"]["birthTimeStatus"];
assert.equal(safeParseReportDocument(badEnum).ok, false);
});
test("charts must contain exactly one D1 with all twelve houses", () => {
const zeroD1 = clone();
zeroD1.charts = zeroD1.charts.filter((chart) => chart.id !== "D1");
assert.deepEqual(findChartSetViolations(zeroD1), ["charts must contain exactly one D1 chart, found 0"]);
assert.equal(safeParseReportDocument(zeroD1).ok, false);
const twoD1 = clone();
twoD1.charts.push(structuredClone(twoD1.charts[0]));
const violations = findChartSetViolations(twoD1);
assert.ok(violations.some((v) => v.includes("duplicate chart id")));
assert.ok(violations.some((v) => v.includes("exactly one D1")));
assert.equal(safeParseReportDocument(twoD1).ok, false);
const elevenHouses = clone();
elevenHouses.charts[0].houses = elevenHouses.charts[0].houses.slice(0, 11);
assert.ok(findChartSetViolations(elevenHouses).some((v) => v.includes("all twelve house numbers")));
assert.equal(safeParseReportDocument(elevenHouses).ok, false);
});
test("duplicate house numbers are rejected per chart", () => {
const duplicated = clone();
duplicated.charts[0].houses[11].houseNumber = 1;
const result = safeParseReportDocument(duplicated);
assert.equal(result.ok, false);
});
test("longitude is [0, 360)", () => {
const at360 = clone();
at360.charts[0].planets![0].longitudeDegrees = 360;
assert.equal(safeParseReportDocument(at360).ok, false);
const near360 = clone();
near360.charts[0].planets![0].longitudeDegrees = 359.999;
assert.equal(safeParseReportDocument(near360).ok, true);
});
test("evidence ids must be globally unique across the appendix", () => {
const duplicated = clone();
duplicated.evidenceAppendix.conflicts[0].id = duplicated.evidenceAppendix.techniqueAudit[0].id;
assert.ok(findDuplicateEvidenceIds(duplicated).some((v) => v.includes("ev-mevg-web")));
assert.equal(safeParseReportDocument(duplicated).ok, false);
});
test("dangling evidenceRefs are rejected", () => {
const dangling = clone();
dangling.thematicNarrative[0].evidenceRefs = ["ev-no-such-evidence"];
assert.deepEqual(findDanglingEvidenceRefs(dangling), ["career:ev-no-such-evidence"]);
assert.equal(safeParseReportDocument(dangling).ok, false);
});
test("blocked sections reject deterministic predictions", () => {
const deterministic = clone();
deterministic.thematicNarrative[3].narrative = "这个事件必然会发生在明年,一定会成功。";
assert.ok(findBlockedDeterministicClaims(deterministic).length > 0);
assert.equal(safeParseReportDocument(deterministic).ok, false);
const nonDeterministic = clone();
nonDeterministic.thematicNarrative[3].narrative = "需要更多历史事件校准后才能评估,具体应期暂不提供。";
assert.equal(findBlockedDeterministicClaims(nonDeterministic).length, 0);
assert.equal(safeParseReportDocument(nonDeterministic).ok, true);
});
const forbiddenSamples = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"file:///Users/jesse/private/chart.json",
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
"onerror=alert(1)",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
"node:internal/modules/cjs/loader",
"Traceback (most recent call last)",
"__dirname/secret",
"__proto__ pollution",
"C:\\Users\\jesse\\chart.json",
"tool_call_id: call_123",
];
test("forbidden content patterns reject executable and internal material", () => {
for (const poison of forbiddenSamples) {
const poisoned = clone();
poisoned.disclaimer = poison;
assert.ok(
findForbiddenContent(poison).length > 0,
`expected ${poison} to be flagged`,
);
assert.equal(safeParseReportDocument(poisoned).ok, false, `poison: ${poison}`);
}
});
test("ordinary Chinese report text is not flagged as forbidden", () => {
assert.equal(findForbiddenContent("事业与财富主题的多系统证据较一致。").length, 0);
assert.equal(findForbiddenContent("A < B 的比较关系不属于 HTML 标签").length, 0);
});
test("serialization size cap rejects oversized documents", () => {
const oversized = clone();
oversized.disclaimer = "字".repeat(REPORT_DOCUMENT_MAX_BYTES);
assert.equal(safeParseReportDocument(oversized).ok, false);
});
test("JSON object key order is not validated (display order is a typed contract)", () => {
const reordered: Record<string, unknown> = {};
for (const key of Object.keys(fixture).reverse()) {
reordered[key] = (fixture as Record<string, unknown>)[key];
}
assert.equal(safeParseReportDocument(reordered).ok, true);
});
test("ReportDocument v1 keeps legacy Skill identity compatibility", () => {
assert.equal(fixture.provenance.skillName, undefined);
assert.equal(fixture.provenance.skillVersion, undefined);
assert.equal(safeParseReportDocument(fixture).ok, true);
});
test("optional Skill name and version accept registry identity and reject malformed values", () => {
const current = clone();
current.provenance.skillName = "jyotish-vedic-astrology";
current.provenance.skillVersion = "6.9.14";
assert.equal(safeParseReportDocument(current).ok, true);
const badName = clone();
badName.provenance.skillName = "Bad Package";
assert.equal(safeParseReportDocument(badName).ok, false);
const badVersion = clone();
badVersion.provenance.skillVersion = "v6";
assert.equal(safeParseReportDocument(badVersion).ok, false);
});
test("server parse recomputes the evidence hash and rejects self-reported mismatches", () => {
// The fixture hash is the canonical cross-language value; recomputation must agree.
assert.equal(computeEvidenceHash(fixture.evidenceAppendix), fixture.provenance.evidenceHash);
assert.equal(
computeEvidenceHash(fixture.evidenceAppendix),
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4",
);
assert.equal(safeParseServerReportDocument(fixture).ok, true);
// Tampered evidence with the self-reported hash left unchanged must fail.
const tampered = clone();
tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值";
const result = safeParseServerReportDocument(tampered);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.path === "provenance.evidenceHash"));
assert.throws(() => parseServerReportDocument(tampered), ReportDocumentValidationError);
// The isomorphic parse alone does not verify the hash (server-only duty).
assert.equal(safeParseReportDocument(tampered).ok, true);
});
test("parseReportDocument throws a typed validation error on guard failures", () => {
const bad = clone();
bad.thematicNarrative[0].evidenceRefs = ["ev-missing"];
assert.throws(() => parseReportDocument(bad), ReportDocumentValidationError);
const parsed = parseReportDocument(fixture);
assert.equal(parsed.schemaVersion, "report_document.v1");
});
test("ReportDocument v2 is canonical and fixture passes all guards", () => {
assert.equal(LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION, "report_document.v1");
assert.equal(REPORT_DOCUMENT_SCHEMA_VERSION, "report_document.v2");
assert.equal(CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION, REPORT_DOCUMENT_SCHEMA_VERSION);
assert.equal(v2Fixture.schemaVersion, REPORT_DOCUMENT_SCHEMA_VERSION);
assert.equal(v2Fixture.depth, "standard");
assert.deepEqual(findThemeCoverageViolations(v2Fixture), []);
assert.deepEqual(findDanglingEvidenceRefs(v2Fixture), []);
assert.deepEqual(findUnsupportedDateClaims(v2Fixture), []);
assert.equal(safeParseReportDocument(v2Fixture).ok, true);
assert.ok(serializedReportDocumentBytes(v2Fixture) <= REPORT_DOCUMENT_MAX_BYTES);
});
test("ReportDocument v2 server read recomputes evidence hash", () => {
assert.equal(computeEvidenceHash(v2Fixture.evidenceAppendix), v2Fixture.provenance.evidenceHash);
assert.equal(
computeEvidenceHash(v2Fixture.evidenceAppendix),
"b4f6d95916b5253054a9c64958eb3deec6ee5b54b0286465e7a986ba701132cf",
);
assert.equal(safeParseServerReportDocument(v2Fixture).ok, true);
const tampered = cloneV2();
tampered.evidenceAppendix.calculationEvidence[0].value = "tampered-v2";
const result = safeParseServerReportDocument(tampered);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.path === "provenance.evidenceHash"));
});
test("every requested v2 theme has exactly one section or blocked disclosure", () => {
const missing = cloneV2();
missing.blockedConflictDisclosure = missing.blockedConflictDisclosure.filter(
(section) => section.theme !== "career",
);
assert.ok(findThemeCoverageViolations(missing).some((error) => error.includes("career") && error.includes("found 0")));
assert.equal(safeParseReportDocument(missing).ok, false);
const doubleCovered = cloneV2();
doubleCovered.thematicNarrative.push({
...structuredClone(doubleCovered.thematicNarrative[0]),
id: "theme-career",
theme: "career",
});
assert.ok(findThemeCoverageViolations(doubleCovered).some((error) => error.includes("career") && error.includes("found 2")));
assert.equal(safeParseReportDocument(doubleCovered).ok, false);
const duplicatedRequest = cloneV2();
duplicatedRequest.requestedThemes.push("general");
assert.ok(findThemeCoverageViolations(duplicatedRequest).some((error) => error.includes("requestedThemes must contain general exactly once, found 2")));
assert.equal(safeParseReportDocument(duplicatedRequest).ok, false);
});
test("v2 thematic sections require their real structured divisional charts", () => {
const unsupportedCareer = cloneV2();
unsupportedCareer.blockedConflictDisclosure = unsupportedCareer.blockedConflictDisclosure.filter(
(section) => section.theme !== "career",
);
unsupportedCareer.thematicNarrative.push({
...structuredClone(unsupportedCareer.thematicNarrative[0]),
id: "theme-career",
theme: "career",
});
assert.ok(findThemeCoverageViolations(unsupportedCareer).some((error) => error.includes("requires structured D10")));
assert.equal(safeParseReportDocument(unsupportedCareer).ok, false);
assert.equal(safeParseReportDocument(v2Fixture).ok, true);
});
test("v2 evidence references close across all new sections and charts", () => {
const mutations: Array<[string, (document: ReportDocumentV2) => void]> = [
["executiveSummary", (document) => { document.executiveSummary.evidenceRefs = ["ev-missing"]; }],
["natalFoundation", (document) => { document.natalFoundation.evidenceRefs = ["ev-missing"]; }],
["currentPhase", (document) => { document.currentPhase!.evidenceRefs = ["ev-missing"]; }],
["actionNotes", (document) => { document.actionNotes[0].evidenceRefs = ["ev-missing"]; }],
["charts", (document) => { document.charts[0].evidenceRefs = ["ev-missing"]; }],
["thematicNarrative", (document) => { document.thematicNarrative[0].evidenceRefs = ["ev-missing"]; }],
["blockedConflictDisclosure", (document) => { document.blockedConflictDisclosure[0].evidenceRefs = ["ev-missing"]; }],
];
for (const [label, mutate] of mutations) {
const dangling = cloneV2();
mutate(dangling);
assert.ok(findDanglingEvidenceRefs(dangling).some((entry) => entry.includes("ev-missing")), label);
assert.equal(safeParseReportDocument(dangling).ok, false, label);
}
});
test("v2 guard rejects unsupported dates, medical diagnoses, and deterministic financial promises", () => {
const unsupportedDate = cloneV2();
unsupportedDate.blockedConflictDisclosure[0].reason = "结论将在2031年5月发生。";
unsupportedDate.blockedConflictDisclosure[0].evidenceRefs = [];
assert.ok(findUnsupportedDateClaims(unsupportedDate).some((error) => error.includes("blockedConflictDisclosure[0]")));
assert.equal(safeParseReportDocument(unsupportedDate).ok, false);
const medical = cloneV2();
medical.actionNotes[0].note = "你已经患有糖尿病。";
assert.ok(findForbiddenContent(medical.actionNotes[0].note).includes("medical_diagnosis"));
assert.equal(safeParseReportDocument(medical).ok, false);
const financial = cloneV2();
financial.actionNotes[0].note = "这个方案保证收益并且稳赚不赔。";
assert.ok(findForbiddenContent(financial.actionNotes[0].note).includes("deterministic_financial_promise"));
assert.equal(safeParseReportDocument(financial).ok, false);
});
test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () => {
for (const poison of ["<div>报告</div>", "body { color: red; }", 'style="color:red"']) {
const document = cloneV2();
document.actionNotes[0].note = poison;
assert.ok(findForbiddenContent(poison).length > 0, poison);
assert.equal(safeParseReportDocument(document).ok, false, poison);
}
const ordinary = cloneV2();
ordinary.actionNotes[0].note = "记录真实事件日期,并把观察与证据逐项核对。";
assert.equal(findForbiddenContent(ordinary.actionNotes[0].note).length, 0);
assert.equal(safeParseReportDocument(ordinary).ok, true);
});