Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions backend/__tests__/logger.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* __tests__/logger.test.js
* Unit tests for utils/logger.js (issue #536).
*
* Verifies log-level filtering and the structured output format (level,
* message, timestamp) by capturing what the logger writes to stdout.
*/

"use strict";

function requireFreshLogger() {
jest.resetModules();
return require("../src/utils/logger");
}

function captureStdout() {
const lines = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk) => {
lines.push(chunk.toString());
return true;
};
return {
lines,
restore() {
process.stdout.write = originalWrite;
},
};
}

describe("logger", () => {
const originalLogLevel = process.env.LOG_LEVEL;
let capture;

afterEach(() => {
capture?.restore();
if (originalLogLevel === undefined) {
delete process.env.LOG_LEVEL;
} else {
process.env.LOG_LEVEL = originalLogLevel;
}
});

describe("log-level filtering", () => {
it("suppresses messages below the configured log level", () => {
process.env.LOG_LEVEL = "warn";
const logger = requireFreshLogger();
capture = captureStdout();

logger.info("this should be suppressed");
logger.debug("this should also be suppressed");

expect(capture.lines).toHaveLength(0);
});

it("emits messages at or above the configured log level", () => {
process.env.LOG_LEVEL = "warn";
const logger = requireFreshLogger();
capture = captureStdout();

logger.warn("this should appear");
logger.error("this should also appear");

expect(capture.lines).toHaveLength(2);
});

it("defaults to info level when LOG_LEVEL is unset", () => {
delete process.env.LOG_LEVEL;
const logger = requireFreshLogger();
capture = captureStdout();

logger.debug("suppressed by default info level");
logger.info("visible at default info level");

expect(logger.level).toBe("info");
expect(capture.lines).toHaveLength(1);
});
});

describe("structured output format", () => {
it("includes level, message, and timestamp fields", () => {
process.env.LOG_LEVEL = "info";
const logger = requireFreshLogger();
capture = captureStdout();

logger.info("hello world");

expect(capture.lines).toHaveLength(1);
const entry = JSON.parse(capture.lines[0]);

expect(entry.level).toBe("INFO");
expect(entry.msg).toBe("hello world");
expect(entry).toHaveProperty("time");
expect(new Date(entry.time).toISOString()).toBe(entry.time);
});

it("uppercases the level label for each severity", () => {
process.env.LOG_LEVEL = "debug";
const logger = requireFreshLogger();
capture = captureStdout();

logger.debug("debug msg");
logger.warn("warn msg");
logger.error("error msg");

const levels = capture.lines.map((line) => JSON.parse(line).level);
expect(levels).toEqual(["DEBUG", "WARN", "ERROR"]);
});

it("merges structured fields passed alongside the message", () => {
process.env.LOG_LEVEL = "info";
const logger = requireFreshLogger();
capture = captureStdout();

logger.info({ userId: "abc123", action: "login" }, "user logged in");

const entry = JSON.parse(capture.lines[0]);
expect(entry.msg).toBe("user logged in");
expect(entry.userId).toBe("abc123");
expect(entry.action).toBe("login");
});
});
});
149 changes: 149 additions & 0 deletions backend/__tests__/sanitization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* __tests__/sanitization.test.js
* Unit tests for the sanitization middleware (issue #535).
*
* Verifies that script/HTML injection payloads are neutralised while valid
* Stellar addresses (and unrelated body fields such as memos/amounts) pass
* through unchanged.
*/

"use strict";

const { sanitizePublicKey, sanitizeUsername } = require("../src/middleware/sanitization");

const VALID_PUBLIC_KEY = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";

function createReq({ params = {}, body = {} } = {}) {
return { params, body };
}

function createRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}

describe("sanitizePublicKey", () => {
it("calls next() and leaves the request untouched when no publicKey param is present", () => {
const req = createReq({ params: {}, body: { memo: "coffee", amount: "5" } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
expect(req.body).toEqual({ memo: "coffee", amount: "5" });
});

it("passes a valid Stellar public key through unchanged", () => {
const req = createReq({ params: { publicKey: VALID_PUBLIC_KEY } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(req.params.publicKey).toBe(VALID_PUBLIC_KEY);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});

it("leaves unrelated body fields such as memo and amount unchanged", () => {
const req = createReq({
params: { publicKey: VALID_PUBLIC_KEY },
body: { memo: "Great work! <3", amount: "12.5000000" },
});
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(req.body).toEqual({ memo: "Great work! <3", amount: "12.5000000" });
expect(next).toHaveBeenCalledTimes(1);
});

it("strips non-alphanumeric injection characters wrapped around a valid key", () => {
const req = createReq({ params: { publicKey: `"'><${VALID_PUBLIC_KEY}<script>` } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

// The <script> tag's letters survive stripping (only non-alphanumeric chars
// are removed), so the sanitized value is no longer a valid 56-char key and
// must be rejected rather than silently passed through.
expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});

it("rejects a pure script/HTML injection payload with a 400 and does not call next()", () => {
const req = createReq({ params: { publicKey: "<script>alert('xss')</script>" } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Invalid Stellar public key format" });
expect(next).not.toHaveBeenCalled();
});

it("rejects a key of the wrong length after sanitization", () => {
const req = createReq({ params: { publicKey: "GTOO_SHORT" } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});

it("rejects a 56-character key that does not start with G", () => {
const invalidPrefix = `A${VALID_PUBLIC_KEY.slice(1)}`;
const req = createReq({ params: { publicKey: invalidPrefix } });
const res = createRes();
const next = jest.fn();

sanitizePublicKey(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});
});

describe("sanitizeUsername", () => {
it("calls next() when no username param is present", () => {
const req = createReq({ params: {} });
const res = createRes();
const next = jest.fn();

sanitizeUsername(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(req.params.username).toBeUndefined();
});

it("trims and lowercases a valid username", () => {
const req = createReq({ params: { username: " Alice " } });
const res = createRes();
const next = jest.fn();

sanitizeUsername(req, res, next);

expect(req.params.username).toBe("alice");
expect(next).toHaveBeenCalledTimes(1);
});

it("passes an already-normalised username through unchanged", () => {
const req = createReq({ params: { username: "bob" } });
const res = createRes();
const next = jest.fn();

sanitizeUsername(req, res, next);

expect(req.params.username).toBe("bob");
expect(next).toHaveBeenCalledTimes(1);
});
});
Loading
Loading