Skip to content

Commit 61a2b46

Browse files
VIA-866 AS Add tests for client-side logger functionality
1 parent ad2c57b commit 61a2b46

5 files changed

Lines changed: 159 additions & 23 deletions

File tree

src/app/_components/client-unhandled-error-logger/ClientUnhandledErrorLogger.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ let router;
1212
const reportClientSideUnhandledError = (errorEvent: ErrorEvent) => {
1313
errorEvent.preventDefault();
1414

15-
//
1615
const errorContext: ClientSideErrorContext = {
1716
message: String(errorEvent.message ?? ""),
1817
filename: String(errorEvent.filename ?? ""),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { DeployEnvironment } from "@src/types/environments";
2+
import config from "@src/utils/config";
3+
import { ClientSideErrorTypes } from "@src/utils/constants";
4+
import { logger } from "@src/utils/logger";
5+
import { ConfigMock, configBuilder } from "@test-data/config/builders";
6+
7+
import logClientSideError from "./client-side-error-logger";
8+
9+
jest.mock("@src/utils/logger", () => ({
10+
logger: {
11+
child: jest.fn().mockReturnValue({
12+
error: jest.fn(),
13+
}),
14+
},
15+
}));
16+
17+
jest.mock("@src/utils/requestScopedStorageWrapper", () => ({
18+
requestScopedStorageWrapper: jest.fn((fn, ...args) => fn(...args)),
19+
}));
20+
21+
jest.mock("@src/utils/client-side-logger-server-actions/error-utils", () => ({
22+
_sanitiseErrorContext: jest.fn((log) => log), // Simple passthrough for testing
23+
}));
24+
25+
describe("logClientSideError Server Action", () => {
26+
const mockConfig = config as ConfigMock;
27+
const mockedLog = logger.child({ module: "" });
28+
29+
beforeEach(() => {
30+
const defaultConfig = configBuilder().build();
31+
Object.assign(mockConfig, defaultConfig);
32+
33+
jest.clearAllMocks();
34+
});
35+
36+
it("logs the error with validated type and sanitised context", async () => {
37+
const context = { message: "Test error" };
38+
39+
await logClientSideError(ClientSideErrorTypes.UNHANDLED_ERROR, context);
40+
41+
expect(mockedLog.error).toHaveBeenCalledWith(
42+
{
43+
context: { clientSideErrorType: ClientSideErrorTypes.UNHANDLED_ERROR },
44+
error: context,
45+
},
46+
"Client side error occurred",
47+
);
48+
});
49+
50+
it("returns true when NOT in production", async () => {
51+
Object.assign(mockConfig, { ...mockConfig, DEPLOY_ENVIRONMENT: DeployEnvironment.dev });
52+
53+
const result = await logClientSideError(ClientSideErrorTypes.UNHANDLED_ERROR);
54+
expect(result).toBe(true);
55+
});
56+
57+
it("returns false when in production", async () => {
58+
Object.assign(mockConfig, { ...mockConfig, DEPLOY_ENVIRONMENT: DeployEnvironment.prod });
59+
60+
const result = await logClientSideError(ClientSideErrorTypes.UNHANDLED_ERROR);
61+
expect(result).toBe(false);
62+
});
63+
});

src/utils/client-side-logger-server-actions/client-side-error-logger.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use server";
22

33
import { DeployEnvironment } from "@src/types/environments";
4+
import { _sanitiseErrorContext } from "@src/utils/client-side-logger-server-actions/error-utils";
45
import config from "@src/utils/config";
56
import { ClientSideErrorTypes } from "@src/utils/constants";
67
import { logger } from "@src/utils/logger";
@@ -9,9 +10,6 @@ import { Logger } from "pino";
910

1011
const log: Logger = logger.child({ module: "client-side-error-logger" });
1112

12-
const MAX_FIELD_LENGTH = 2000;
13-
const ALLOWED_CONTEXT_KEYS: string[] = ["message", "stack", "digest", "filename", "lineno", "colno"];
14-
1513
export interface ClientSideErrorContext {
1614
message?: string;
1715
stack?: string;
@@ -21,24 +19,6 @@ export interface ClientSideErrorContext {
2119
colno?: string;
2220
}
2321

24-
const sanitiseErrorContext = (rawContext?: unknown): Record<string, string> | undefined => {
25-
if (rawContext == null || typeof rawContext !== "object") return undefined;
26-
27-
const sanitisedContext: Record<string, string> = {};
28-
const raw = rawContext as Record<string, unknown>;
29-
30-
for (const key of ALLOWED_CONTEXT_KEYS) {
31-
if (key in raw && typeof raw[key] === "string") {
32-
sanitisedContext[key] = (raw[key] as string)
33-
.slice(0, MAX_FIELD_LENGTH)
34-
// Only allow 'tab', 'newline', 'return' control characters
35-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
36-
}
37-
}
38-
39-
return Object.keys(sanitisedContext).length > 0 ? sanitisedContext : undefined;
40-
};
41-
4222
const logClientSideError = async (
4323
clientSideErrorType: ClientSideErrorTypes,
4424
errorContext?: ClientSideErrorContext,
@@ -55,7 +35,7 @@ const logClientSideErrorAction = async (
5535
? clientSideErrorType
5636
: ClientSideErrorTypes.UNKNOWN_ERROR_REASON;
5737

58-
const sanitisedErrorContext = sanitiseErrorContext(rawErrorContext);
38+
const sanitisedErrorContext = _sanitiseErrorContext(rawErrorContext);
5939

6040
log.error(
6141
{
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { _sanitiseErrorContext } from "./error-utils";
2+
3+
describe("_sanitiseErrorContext", () => {
4+
it("returns undefined for non-object inputs", () => {
5+
expect(_sanitiseErrorContext(null)).toBeUndefined();
6+
expect(_sanitiseErrorContext(undefined)).toBeUndefined();
7+
expect(_sanitiseErrorContext("string error")).toBeUndefined();
8+
expect(_sanitiseErrorContext(123)).toBeUndefined();
9+
});
10+
11+
it("should filter out keys that are not in the allowlist", () => {
12+
const raw = {
13+
message: "Something went wrong",
14+
test: "test key",
15+
};
16+
const result = _sanitiseErrorContext(raw);
17+
18+
expect(result).toHaveProperty("message");
19+
expect(result).not.toHaveProperty("test");
20+
});
21+
22+
it("should only include keys that have string values", () => {
23+
const raw = {
24+
message: "Valid message",
25+
lineno: 42,
26+
colno: "10",
27+
};
28+
const result = _sanitiseErrorContext(raw);
29+
30+
expect(result).toEqual({
31+
message: "Valid message",
32+
colno: "10",
33+
});
34+
expect(result).not.toHaveProperty("lineno");
35+
});
36+
37+
it("should truncate fields longer than 2000 characters", () => {
38+
const longString = "a".repeat(3000);
39+
const result = _sanitiseErrorContext({ message: longString });
40+
41+
expect(result?.message).toHaveLength(2000);
42+
expect(result?.message).toBe(longString.slice(0, 2000));
43+
});
44+
45+
it("should remove forbidden control characters but keeps tabs and newlines", () => {
46+
// \x00 is 'Null Character' (forbidden), \n is 'Newline' (allowed), \t is 'Tab' (allowed)
47+
const raw = {
48+
message: "Hello\x00World\nThis\tis\rfine",
49+
};
50+
const result = _sanitiseErrorContext(raw);
51+
52+
expect(result?.message).toBe("HelloWorld\nThis\tis\rfine");
53+
});
54+
55+
it("should return undefined if the final object is empty", () => {
56+
const raw = { someRandomKey: "no allowed keys here" };
57+
expect(_sanitiseErrorContext(raw)).toBeUndefined();
58+
});
59+
60+
it("should handle a full valid error context object", () => {
61+
const fullContext = {
62+
message: "Error message",
63+
stack: "Error: message\n at Object.<anonymous>...",
64+
digest: "next-digest-123",
65+
filename: "index.ts",
66+
lineno: "5",
67+
colno: "12",
68+
};
69+
const result = _sanitiseErrorContext(fullContext);
70+
expect(result).toEqual(fullContext);
71+
});
72+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const MAX_FIELD_LENGTH = 2000;
2+
const ALLOWED_CONTEXT_KEYS: string[] = ["message", "stack", "digest", "filename", "lineno", "colno"];
3+
4+
const _sanitiseErrorContext = (rawContext?: unknown): Record<string, string> | undefined => {
5+
if (rawContext == null || typeof rawContext !== "object") return undefined;
6+
7+
const sanitisedContext: Record<string, string> = {};
8+
const raw = rawContext as Record<string, unknown>;
9+
10+
for (const key of ALLOWED_CONTEXT_KEYS) {
11+
if (key in raw && typeof raw[key] === "string") {
12+
sanitisedContext[key] = (raw[key] as string)
13+
.slice(0, MAX_FIELD_LENGTH)
14+
// Only allow 'tab', 'newline', 'return' control characters
15+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
16+
}
17+
}
18+
19+
return Object.keys(sanitisedContext).length > 0 ? sanitisedContext : undefined;
20+
};
21+
22+
export { _sanitiseErrorContext };

0 commit comments

Comments
 (0)