mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
6274705848
Frontend half of the logging overhaul. Capture: - Add a global error boundary plus window error/unhandledrejection handlers that persist renderer errors via tauri-plugin-log (new @tauri-apps/plugin-log dependency + log:default capability), so a white-screen crash leaves a trace. Redaction (two layers over every logged value): - Structured serializer: redact by sensitive property name (arrays and nested values included) and by opaque value shape. - Text layer, the universal final pass: redact URL credentials/query, auth schemes, known secret shapes, and named-key array/object values. The container rule closes prefix+JSON, double-encoded, and POJO-error bypasses regardless of which entrypoint the structured data came from. - Render Errors as redacted message + native stack across V8 and WebKit (macOS/Linux) without depending on a single stack format. - Bound all inputs and drop oversized JSON to keep the UI responsive. i18n: - zh / en / ja / zh-TW for the new log settings and error text.
328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
const writeErrorLog = vi.hoisted(() =>
|
||
vi.fn<(message: string, options: { file: string }) => Promise<void>>(() =>
|
||
Promise.resolve(),
|
||
),
|
||
);
|
||
|
||
vi.mock("@tauri-apps/plugin-log", () => ({
|
||
error: writeErrorLog,
|
||
}));
|
||
|
||
import {
|
||
installGlobalErrorHandlers,
|
||
redactFrontendLogText,
|
||
reportFrontendError,
|
||
} from "@/lib/frontendLogger";
|
||
|
||
describe("frontendLogger", () => {
|
||
beforeEach(() => {
|
||
writeErrorLog.mockClear();
|
||
});
|
||
|
||
it("redacts URL parameters and named credentials", () => {
|
||
const redacted = redactFrontendLogText(
|
||
"https://example.test/path?apiKey=query-secret&name=alice\n" +
|
||
'api_key: "config secret with spaces"\n' +
|
||
"Authorization: Bearer bearer-secret\n" +
|
||
"Authorization: Basic dXNlcjpwYXNz\n" +
|
||
"Authorization: Token token-secret\n" +
|
||
" Cookie: session=cookie-secret; preference=private\n" +
|
||
"ApiKey standalone-secret\n" +
|
||
"https://user:password@example.test/private",
|
||
);
|
||
|
||
expect(redacted).not.toContain("query-secret");
|
||
expect(redacted).not.toContain("alice");
|
||
expect(redacted).not.toContain("config secret with spaces");
|
||
expect(redacted).not.toContain("secret with spaces");
|
||
expect(redacted).not.toContain("bearer-secret");
|
||
expect(redacted).not.toContain("dXNlcjpwYXNz");
|
||
expect(redacted).not.toContain("token-secret");
|
||
expect(redacted).not.toContain("standalone-secret");
|
||
expect(redacted).not.toContain("cookie-secret");
|
||
expect(redacted).not.toContain("preference=private");
|
||
expect(redacted).not.toContain("user:password");
|
||
expect(redacted).toContain("apiKey=[REDACTED]");
|
||
expect(redacted).toContain('api_key: "[REDACTED]"');
|
||
|
||
const escapedMultiline = redactFrontendLogText(
|
||
'{"detail":"line1\\nsk-ant-api03-escaped-secret"}',
|
||
);
|
||
expect(escapedMultiline).not.toContain("sk-ant-api03-escaped-secret");
|
||
});
|
||
|
||
it("writes bounded, redacted errors through the Tauri log plugin", () => {
|
||
const secret = "sensitive-token";
|
||
const oversizedDetails = `${"x".repeat(2_000_000)} token=${secret}`;
|
||
|
||
reportFrontendError(
|
||
"window.error",
|
||
new Error(`failed at https://example.test/?key=${secret}`),
|
||
oversizedDetails,
|
||
);
|
||
|
||
expect(writeErrorLog).toHaveBeenCalledOnce();
|
||
const [message, options] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain(secret);
|
||
expect(message).toContain("[truncated]");
|
||
expect(message.length).toBeLessThanOrEqual(12_020);
|
||
expect(options).toEqual({ file: "frontend" });
|
||
});
|
||
|
||
it("serializes object-shaped rejection reasons without exposing secrets", () => {
|
||
// 两层脱敏契约:
|
||
// - 属性名一级:命中敏感名(含复数/数组/嵌套)整个值一律隐藏;
|
||
// - 值一级:任意位置的“不透明密钥形状”按形状隐藏;
|
||
// - 文本一级:序列化后 `"name":"value"` 的裸密钥由正则兜底。
|
||
reportFrontendError("unhandledrejection", {
|
||
code: 500,
|
||
message: "auth failed", // 良性文本保留(值含 "auth" 但非 name:value)
|
||
key: "short-secret", // 裸 key 标量(非不透明) → 属性名层
|
||
token: "object-secret", // 标量命名 → 属性名层
|
||
tokens: ["k-9f3a7c2b1e"], // 复数+数组(短值) → 属性名层(去尾 s + 整体隐藏)
|
||
auth: ["opaque-credential"], // 数组 → 属性名层
|
||
credential: "AIzaRealCredential123",
|
||
nested: { detail: "ghp_abcdef123456" }, // 非敏感名,靠不透明形状
|
||
values: ["eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2lnbmF0dXJl"], // 数组内不透明形状
|
||
multiline: "line1\nsk-ant-api03-multiline-secret", // 串内不透明形状
|
||
session: { activeTab: "providers", scrollPos: 120 }, // 良性状态原样保留
|
||
});
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).toContain('"code":500');
|
||
expect(message).toContain('"message":"auth failed"');
|
||
expect(message).not.toContain("short-secret");
|
||
expect(message).not.toContain("object-secret");
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).not.toContain("opaque-credential");
|
||
expect(message).not.toContain("AIzaRealCredential123");
|
||
expect(message).not.toContain("ghp_abcdef123456");
|
||
expect(message).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||
expect(message).not.toContain("sk-ant-api03-multiline-secret");
|
||
expect(message).toContain('"key":"[REDACTED]"');
|
||
expect(message).toContain('"token":"[REDACTED]"');
|
||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||
expect(message).toContain('"auth":"[REDACTED]"');
|
||
expect(message).toContain('"credential":"[REDACTED]"');
|
||
expect(message).toContain(
|
||
'"session":{"activeTab":"providers","scrollPos":120}',
|
||
);
|
||
});
|
||
|
||
it("applies property-level redaction to stringified-JSON rejection reasons", () => {
|
||
// 字符串形态的 JSON 不能只靠文本正则——数组/复数/裸标量字段会漏。
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
'{"tokens":["k-9f3a7c2b1e"],"auth":["opaque-credential"],"key":"short-secret","keepMe":"visible"}',
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).not.toContain("opaque-credential");
|
||
expect(message).not.toContain("short-secret");
|
||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||
expect(message).toContain('"auth":"[REDACTED]"');
|
||
expect(message).toContain('"key":"[REDACTED]"');
|
||
expect(message).toContain('"keepMe":"visible"');
|
||
});
|
||
|
||
it("keeps non-JSON error strings as readable text", () => {
|
||
reportFrontendError("window.error", "plain failure at step 3");
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).toContain("plain failure at step 3");
|
||
});
|
||
|
||
it("applies property-level redaction to JSON wrapped in an Error message", () => {
|
||
// throw new Error(JSON.stringify(payload)) 会把凭据藏进 message,
|
||
// 而 error.stack 第一行原样吐出 message —— 必须先对 message 结构化脱敏。
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
new Error(
|
||
'{"tokens":["k-9f3a7c2b1e"],"key":"short-secret","keepMe":"visible"}',
|
||
),
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).not.toContain("short-secret");
|
||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||
expect(message).toContain('"key":"[REDACTED]"');
|
||
expect(message).toContain('"keepMe":"visible"');
|
||
});
|
||
|
||
it("omits oversized JSON error strings instead of leaking truncated fields", () => {
|
||
// 合法但超长的 JSON 若先截断再 parse,必成非法 JSON 而退回文本层,数组字段泄漏。
|
||
const padding = "x".repeat(20_000);
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`,
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).toContain("[oversized structured error omitted]");
|
||
});
|
||
|
||
it("omits oversized JSON wrapped in an Error message", () => {
|
||
const padding = "x".repeat(20_000);
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
new Error(`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`),
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).toContain("[oversized structured error omitted]");
|
||
});
|
||
|
||
it("redacts array credentials in prefix+JSON rejection strings", () => {
|
||
// 前缀 + JSON:`redactStructuredString` 的 startsWith 门被前缀挡掉,退回文本层。
|
||
// 文本层的容器正则必须在这个统一出口兜住 `"tokens":[...]`。
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
'Load failed: {"tokens":["ak_live_7f3d9b21c8e4"]}',
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
|
||
expect(message).toContain("Load failed");
|
||
});
|
||
|
||
it("redacts array credentials in prefix+JSON wrapped in an Error", () => {
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
new Error(
|
||
"Provider provisioning failed: " +
|
||
JSON.stringify({ apiKeys: ["ak_live_7f3d9b21c8e4"] }),
|
||
),
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
|
||
});
|
||
|
||
it("redacts credentials in double-encoded nested JSON (escaped quotes)", () => {
|
||
reportFrontendError(
|
||
"unhandledrejection",
|
||
new Error(
|
||
JSON.stringify({
|
||
status: 400,
|
||
body: '{"keys":["abcd1234efgh5678ij"]}',
|
||
}),
|
||
),
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("abcd1234efgh5678ij");
|
||
});
|
||
|
||
it("redacts array credentials in a POJO (non-Error) rejection", () => {
|
||
reportFrontendError("unhandledrejection", {
|
||
name: "HttpError",
|
||
message: '{"tokens":["opaqueTokenValue12345"]}',
|
||
code: 400,
|
||
});
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("opaqueTokenValue12345");
|
||
});
|
||
|
||
it("redacts container values only under sensitive keys", () => {
|
||
// 容器正则只对敏感键生效:普通数组/对象(items/config)与后缀含 key 的词(monkey)不误伤。
|
||
const redacted = redactFrontendLogText(
|
||
'{"tokens":["k-secret-1"],"monkey":["visible-a"],"items":["visible-b"],"config":{"theme":"dark"}}',
|
||
);
|
||
|
||
expect(redacted).not.toContain("k-secret-1");
|
||
expect(redacted).toContain("visible-a");
|
||
expect(redacted).toContain("visible-b");
|
||
expect(redacted).toContain('"theme":"dark"');
|
||
});
|
||
|
||
it("preserves native WebKit-style stack frames for JSON-wrapped errors", () => {
|
||
// macOS/Linux 的 WKWebView 用 `fn@file:line:col` 格式,且 stack 不含 message。
|
||
// 旧的 `/^\s+at\s/` 过滤会把整段栈丢掉;新实现须补脱敏头并保留原生栈。
|
||
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
|
||
Object.defineProperty(err, "stack", {
|
||
value:
|
||
"handleClick@tauri://localhost/assets/index.js:42:15\n" +
|
||
"dispatch@tauri://localhost/assets/index.js:99:3",
|
||
});
|
||
|
||
reportFrontendError("unhandledrejection", err);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).toContain('"tokens":"[REDACTED]"'); // 脱敏 message 头
|
||
expect(message).toContain("handleClick@tauri://localhost"); // 原生栈帧保留
|
||
expect(message).toContain("dispatch@tauri://localhost");
|
||
});
|
||
|
||
it("replaces every occurrence of the raw message in a V8-style stack", () => {
|
||
// message 若在 stack 里出现多次(eval/匿名帧回显),字面量替换必须全部换掉,零残留。
|
||
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
|
||
Object.defineProperty(err, "stack", {
|
||
value:
|
||
'Error: {"tokens":["k-9f3a7c2b1e"]}\n' +
|
||
' at eval (eval at <anonymous>, {"tokens":["k-9f3a7c2b1e"]}:1:1)\n' +
|
||
" at run (index.js:10:5)",
|
||
});
|
||
|
||
reportFrontendError("unhandledrejection", err);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||
expect(message).toContain("at run (index.js:10:5)"); // 栈帧保留
|
||
});
|
||
|
||
it("redacts standalone secret shapes in ordinary error text", () => {
|
||
reportFrontendError(
|
||
"window.error",
|
||
new Error("request failed with sk-ant-api03-real-secret"),
|
||
);
|
||
|
||
const [message] = writeErrorLog.mock.calls[0];
|
||
expect(message).not.toContain("sk-ant-api03-real-secret");
|
||
expect(message).toContain("[REDACTED]");
|
||
});
|
||
|
||
it("handles circular rejection objects", () => {
|
||
const reason: Record<string, unknown> = { message: "failed" };
|
||
reason.self = reason;
|
||
|
||
expect(() =>
|
||
reportFrontendError("unhandledrejection", reason),
|
||
).not.toThrow();
|
||
expect(writeErrorLog.mock.calls[0][0]).toContain("[Circular]");
|
||
});
|
||
|
||
it("captures global errors and unhandled rejections and can uninstall", () => {
|
||
const target = new EventTarget() as unknown as Window;
|
||
const uninstall = installGlobalErrorHandlers(target);
|
||
|
||
const errorEvent = new Event("error") as ErrorEvent;
|
||
Object.defineProperties(errorEvent, {
|
||
error: { value: new Error("render failed") },
|
||
filename: { value: "app.js" },
|
||
lineno: { value: 10 },
|
||
colno: { value: 4 },
|
||
});
|
||
target.dispatchEvent(errorEvent);
|
||
|
||
const rejectionEvent = new Event(
|
||
"unhandledrejection",
|
||
) as PromiseRejectionEvent;
|
||
Object.defineProperty(rejectionEvent, "reason", {
|
||
value: new Error("request failed"),
|
||
});
|
||
target.dispatchEvent(rejectionEvent);
|
||
|
||
expect(writeErrorLog).toHaveBeenCalledTimes(2);
|
||
|
||
uninstall();
|
||
target.dispatchEvent(errorEvent);
|
||
expect(writeErrorLog).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|