208 lines
8.4 KiB
TypeScript
208 lines
8.4 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,
|
|
findForbiddenContent,
|
|
parseReportDocument,
|
|
REPORT_DOCUMENT_MAX_BYTES,
|
|
safeParseReportDocument,
|
|
serializedReportDocumentBytes,
|
|
type ReportDocumentV1,
|
|
} 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;
|
|
|
|
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");
|
|
});
|