fix: rebuild conversational public errors
This commit is contained in:
@@ -57,6 +57,8 @@ const errorDefinitions = {
|
||||
|
||||
export type ConversationalRectificationErrorCode = keyof typeof errorDefinitions;
|
||||
|
||||
const trustedErrorCodes = new WeakMap<object, ConversationalRectificationErrorCode>();
|
||||
|
||||
export type ConversationalRectificationPublicError = Readonly<{
|
||||
code: ConversationalRectificationErrorCode;
|
||||
status: number;
|
||||
@@ -65,10 +67,13 @@ export type ConversationalRectificationPublicError = Readonly<{
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
function createPublicError(code: ConversationalRectificationErrorCode): ConversationalRectificationPublicError {
|
||||
const definition = errorDefinitions[code];
|
||||
function createPublicError(code: unknown): ConversationalRectificationPublicError {
|
||||
const safeCode = typeof code === "string" && Object.hasOwn(errorDefinitions, code)
|
||||
? code as ConversationalRectificationErrorCode
|
||||
: "service_unavailable";
|
||||
const definition = errorDefinitions[safeCode];
|
||||
return Object.freeze({
|
||||
code,
|
||||
code: safeCode,
|
||||
status: definition.status,
|
||||
error: definition.error,
|
||||
message: definition.message,
|
||||
@@ -92,20 +97,26 @@ export class ConversationalRectificationError extends Error {
|
||||
this.code = code;
|
||||
this.status = definition.status;
|
||||
this.public = createPublicError(code);
|
||||
trustedErrorCodes.set(this, code);
|
||||
}
|
||||
}
|
||||
|
||||
function getTrustedErrorCode(error: unknown): ConversationalRectificationErrorCode | undefined {
|
||||
if (error === null || typeof error !== "object") return undefined;
|
||||
|
||||
const trustedCode = trustedErrorCodes.get(error);
|
||||
if (!trustedCode) return undefined;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
||||
return descriptor && "value" in descriptor && descriptor.value === trustedCode
|
||||
? trustedCode
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only error mapper intended for route responses. It returns a plain, frozen DTO
|
||||
* and never keeps the unknown input or any of its properties reachable.
|
||||
*/
|
||||
export function toConversationalRectificationPublicError(error: unknown): ConversationalRectificationPublicError {
|
||||
return error instanceof ConversationalRectificationError
|
||||
? error.public
|
||||
: createPublicError("service_unavailable");
|
||||
}
|
||||
|
||||
/** @deprecated Use toConversationalRectificationPublicError for route responses. */
|
||||
export function toConversationalRectificationError(error: unknown): ConversationalRectificationPublicError {
|
||||
return toConversationalRectificationPublicError(error);
|
||||
return createPublicError(getTrustedErrorCode(error));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import {
|
||||
ConversationalRectificationError,
|
||||
toConversationalRectificationError,
|
||||
toConversationalRectificationPublicError,
|
||||
} from "../src/lib/conversational-rectification/errors.ts";
|
||||
|
||||
@@ -143,6 +142,75 @@ test("maps unknown failures to a complete non-leaking public DTO", () => {
|
||||
assertNoReachableText(recovered, rawMessage);
|
||||
assert.equal(JSON.stringify(recovered).includes(rawMessage), false);
|
||||
assert.equal(Reflect.set(recovered, "error", rawMessage), false);
|
||||
|
||||
assert.deepEqual(toConversationalRectificationError(rawFailure), recovered);
|
||||
});
|
||||
|
||||
function assertExactSafePublicDto(
|
||||
value: unknown,
|
||||
expected: {
|
||||
code: string;
|
||||
status: number;
|
||||
error: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
},
|
||||
rawMessage: string,
|
||||
) {
|
||||
assert.deepEqual(value, expected);
|
||||
assert.equal(Object.getPrototypeOf(value), Object.prototype);
|
||||
assert.deepEqual(Reflect.ownKeys(value).sort(), ["code", "error", "message", "retryable", "status"]);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
assert.equal("cause" in (value as object), false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(value)), expected);
|
||||
assertNoReachableText(value, rawMessage);
|
||||
}
|
||||
|
||||
test("rebuilds safe DTOs from forged or mutated recognized errors", () => {
|
||||
const rawMessage = "raw browser SQL model cause";
|
||||
const expectedStale = {
|
||||
code: "stale_turn",
|
||||
status: 409,
|
||||
error: "校正进度已更新",
|
||||
message: "请加载最新进度后再试。",
|
||||
retryable: true,
|
||||
};
|
||||
const expectedUnavailable = {
|
||||
code: "service_unavailable",
|
||||
status: 503,
|
||||
error: "生时校正暂时不可用",
|
||||
message: "服务暂时不可用,请稍后重试。",
|
||||
retryable: true,
|
||||
};
|
||||
const mutatedPublic = new ConversationalRectificationError("stale_turn");
|
||||
const poisonedPublic = {
|
||||
...expectedStale,
|
||||
message: rawMessage,
|
||||
cause: new Error(rawMessage),
|
||||
};
|
||||
Object.defineProperty(mutatedPublic, "public", { value: poisonedPublic });
|
||||
Object.assign(mutatedPublic, { cause: new Error(rawMessage), rawMessage });
|
||||
|
||||
const rebuilt = toConversationalRectificationPublicError(mutatedPublic);
|
||||
assert.notStrictEqual(rebuilt, poisonedPublic);
|
||||
assertExactSafePublicDto(rebuilt, expectedStale, rawMessage);
|
||||
|
||||
const mutatedCode = new ConversationalRectificationError("stale_turn");
|
||||
Object.defineProperty(mutatedCode, "code", { value: "forged_code" });
|
||||
assertExactSafePublicDto(
|
||||
toConversationalRectificationPublicError(mutatedCode),
|
||||
expectedUnavailable,
|
||||
rawMessage,
|
||||
);
|
||||
|
||||
const forged = Object.create(ConversationalRectificationError.prototype);
|
||||
Object.assign(forged, {
|
||||
code: "stale_turn",
|
||||
public: poisonedPublic,
|
||||
cause: new Error(rawMessage),
|
||||
rawMessage,
|
||||
});
|
||||
assertExactSafePublicDto(
|
||||
toConversationalRectificationPublicError(forged),
|
||||
expectedUnavailable,
|
||||
rawMessage,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user