Files
Jyotisha/frontend/tests/rectification-v9-status-security.test.ts
T
Jesse_Chen c7dfe1c166
Staging Backend Quality Gate / validate (pull_request) Successful in 16m51s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
fix(rectification): bind evidence to current server turn
2026-08-12 12:12:10 +08:00

332 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
acceptV9Candidate,
confirmV9BirthTime,
safeToolErrorCode,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
canTransitToTerminal,
evidenceWritesAllowed,
isTerminalStatus,
} from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
EVIDENCE_ID,
RESULT_ID,
SESSION_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
test("accepted is never upgraded to confirmed by the accept path", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: false,
}),
});
const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
assert.equal(result.status, "accepted");
assert.equal(result.caseStatus, "candidate_accepted");
assert.notEqual(result.status, "confirmed");
});
test("confirmed requires the engine gate plus explicit grounded consent", async () => {
// confirmation_allowed=false on the stored result blocks confirmation.
const blocked = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_confirmation_blocked");
},
});
await assert.rejects(
confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "05:02",
consentQuote: "就用05:02",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("confirmation_blocked"),
);
// Confirming a time that is not the representative minute is rejected.
const mismatch = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_confirm_time_mismatch");
},
});
await assert.rejects(
confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "04:55",
consentQuote: "就用04:55",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("confirm_time_mismatch"),
);
// Consent quote must be grounded in the source turn's message.
const ungrounded = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_consent_not_grounded");
},
});
await assert.rejects(
confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "05:02",
consentQuote: "用户根本没说过这句话",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("consent_not_grounded"),
);
});
test("confirm-birth-time binds consent to the server-owned current turn", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
confirm_agentic_rectification_birth_time: () => ({
success: true,
saved_time: "05:02",
status: "confirmed",
result_id: RESULT_ID,
case_status: "confirmed",
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const confirm = tools["rectification-confirm-birth-time"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
execute(input: unknown): Promise<unknown>;
};
const input = {
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
consentQuote: "就用05:02",
};
assert.equal(confirm.inputSchema.safeParse(input).success, true);
assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false);
await confirm.execute(input);
const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time");
assert.ok(call);
assert.equal(call.args.p_source_turn_id, TURN_ID);
});
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: false,
}),
});
await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case");
assert.ok(call);
assert.equal(call.args.p_case_id, CASE_ID);
assert.equal(call.args.p_user_id, USER_ID);
assert.equal(call.args.p_result_id, RESULT_ID);
});
test("accept is idempotent: replaying the same selection succeeds without a second write", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: true,
}),
});
const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
assert.equal(result.idempotent, true);
assert.equal(result.status, "accepted");
});
test("terminal cases reject evidence writes and candidate actions", async () => {
assert.equal(evidenceWritesAllowed("confirmed"), false);
assert.equal(evidenceWritesAllowed("closed"), false);
assert.equal(evidenceWritesAllowed("superseded"), false);
assert.equal(isTerminalStatus("confirmed"), true);
assert.equal(canTransitToTerminal("confirmed", "closed"), false);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "closed" }),
propose_agentic_rectification_evidence: () => {
throw new Error("agentic_rectification_case_terminal");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
}),
(error: unknown) => error instanceof Error && error.message.includes("case_terminal"),
);
});
test("baseline change invalidates results and forces needs_rebaseline", () => {
// The migration ships a profile guard trigger; the service exposes the
// status contract that resumable cases may enter needs_rebaseline.
assert.equal(canTransitToTerminal("collecting_evidence", "confirmed"), true);
// A needs_rebaseline case can still collect evidence but never reference
// stale candidates; evidence writes remain allowed while resumable.
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
});
test("read-case gives the Agent authoritative birth context without raw internals", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: candidateSnapshotFixture(),
}),
get_agentic_rectification_case_compute: () => computeFixture(),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const projection = await (tools["rectification-read-case"] as unknown as {
execute(input: unknown): Promise<Record<string, unknown>>;
}).execute({ caseId: CASE_ID });
assert.deepEqual(projection.birth_context, {
birth_date: "1997-08-08",
reported_birth_time: "05:00",
active_birth_time: null,
candidate_range: CANDIDATE_RANGE,
birthplace: {
label: "河北省邯郸市",
latitude: 36.420487,
longitude: 114.209936,
},
timezone: {
id: "Asia/Shanghai",
offset: 8,
},
});
const serialized = JSON.stringify(projection);
assert.doesNotMatch(serialized, /baseline_birth_snapshot/);
assert.doesNotMatch(serialized, /event_contribution_matrix|rule_ids|raw_score|weight/);
});
test("safe tool error mapping downgrades unknown engine failures", () => {
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_quote_not_grounded")),
"quote_not_grounded",
);
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_case_terminal")),
"case_terminal",
);
assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed");
});
test("compare-candidates refuses to run without scorable evidence", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [],
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof Error && error.message.includes("no_scorable_evidence"),
);
});
test("close-case is a user completion, never an engine confirmation", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
close_agentic_rectification_case: () => ({
success: true,
case_id: CASE_ID,
status: "closed",
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const result = await (tools["rectification-close-case"] as unknown as {
execute(input: unknown): Promise<{ status: string }>;
}).execute({ caseId: CASE_ID, reason: "completed_by_user" });
assert.equal(result.status, "closed");
assert.notEqual(result.status, "confirmed");
const call = accounting.calls.find((item) => item.fn === "close_agentic_rectification_case");
assert.ok(call);
assert.equal(call.args.p_reason, "completed_by_user");
});
test("no sensitive birth data in candidate accept inputs", () => {
const accounting = fakeAccounting({});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const schema = (tools["rectification-accept-candidate"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
}).inputSchema;
const valid = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: "05:02" });
assert.equal(valid.success, true);
const withBirth = schema.safeParse({
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
birth_date: "1997-08-08",
active_birth_time: "05:00",
});
assert.equal(withBirth.success, false);
void CANDIDATE_RANGE;
void EVIDENCE_ID;
void SESSION_ID;
});