Files
CC-Switch/tests/lib/frontendLogger.test.ts
T
Jason 6274705848 feat(logging): capture frontend errors to disk with structured redaction
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.
2026-07-21 16:39:33 +08:00

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});