From e1c04aa8c8c5b3313e168a9e57e950c794e700a6 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 25 Apr 2026 18:44:15 +1000 Subject: [PATCH 1/7] test(utils): add tests for misc, MemCache, and DiskCache Adds 54 new specs covering the three core utility modules: - misc: env(), seconds(), ms(), HTTPError (19 specs) - MemCache: set/get, TTL expiry, has, getOr, delete, clear, expires, invalidate (24 specs) - DiskCache: set/get round-trip, TTL expiry, persistence across instances, invalidate, path traversal prevention (11 specs) --- tests/utils/disk-cache.test.js | 115 +++++++++++++++++++ tests/utils/mem-cache.test.js | 194 +++++++++++++++++++++++++++++++++ tests/utils/misc.test.js | 128 ++++++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 tests/utils/disk-cache.test.js create mode 100644 tests/utils/mem-cache.test.js create mode 100644 tests/utils/misc.test.js diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js new file mode 100644 index 00000000..bc870db0 --- /dev/null +++ b/tests/utils/disk-cache.test.js @@ -0,0 +1,115 @@ +import { DiskCache } from "../../build/utils/disk-cache.js"; +import { mkdtemp, rm } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("utils/DiskCache", () => { + let tempDir; + let originalDataDir; + + beforeEach(async () => { + originalDataDir = process.env.DATA_DIR; + tempDir = await mkdtemp(join(tmpdir(), "disk-cache-test-")); + process.env.DATA_DIR = tempDir; + }); + + afterEach(async () => { + if (originalDataDir !== undefined) { + process.env.DATA_DIR = originalDataDir; + } else { + delete process.env.DATA_DIR; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("round-trips set and get", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await cache.set("greeting", "hello"); + const result = await cache.get("greeting"); + expect(result).toBe("hello"); + }); + + it("returns undefined for missing key", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + const result = await cache.get("nonexistent"); + expect(result).toBeUndefined(); + }); + + it("stores complex objects", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + const data = { name: "test", items: [1, 2, 3], nested: { a: true } }; + await cache.set("complex", data); + const result = await cache.get("complex"); + expect(result).toEqual(data); + }); + + it("overwrites existing values", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await cache.set("key", "old"); + await cache.set("key", "new"); + expect(await cache.get("key")).toBe("new"); + }); + + it("persists to disk and reads back in a fresh instance", async () => { + const opts = { ttl: 60_000, path: "test-cache" }; + const cache1 = new DiskCache(opts); + await cache1.set("persist", "data"); + + // A new instance should read from disk + const cache2 = new DiskCache(opts); + expect(await cache2.get("persist")).toBe("data"); + }); + + describe("TTL expiry", () => { + it("returns undefined for expired entries", async () => { + // Use a very short TTL + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("key", "value"); + + // Wait briefly to ensure TTL expires + await new Promise(resolve => setTimeout(resolve, 10)); + expect(await cache.get("key")).toBeUndefined(); + }); + + it("returns stale value when allowStale is true", async () => { + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("key", "value"); + + await new Promise(resolve => setTimeout(resolve, 10)); + expect(await cache.get("key", true)).toBe("value"); + }); + }); + + describe("invalidate()", () => { + it("removes expired entries from memory and disk", async () => { + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("a", 1); + await cache.set("b", 2); + + await new Promise(resolve => setTimeout(resolve, 10)); + await cache.invalidate(); + + expect(await cache.get("a", true)).toBeUndefined(); + expect(await cache.get("b", true)).toBeUndefined(); + }); + }); + + describe("path traversal prevention", () => { + it("rejects keys with path traversal (..)", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync(cache.set("foo/../evil", "data")).toBeRejected(); + }); + + it("rejects keys with double slashes", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync(cache.set("foo//bar", "data")).toBeRejected(); + }); + + it("rejects keys with dot segments", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync(cache.set("foo/./bar", "data")).toBeRejected(); + }); + }); +}); diff --git a/tests/utils/mem-cache.test.js b/tests/utils/mem-cache.test.js new file mode 100644 index 00000000..93aeb197 --- /dev/null +++ b/tests/utils/mem-cache.test.js @@ -0,0 +1,194 @@ +import { MemCache } from "../../build/utils/mem-cache.js"; + +describe("utils/MemCache", () => { + describe("set() and get()", () => { + it("stores and retrieves a value", () => { + const cache = new MemCache(10_000); + cache.set("key1", "value1"); + expect(cache.get("key1")).toBe("value1"); + }); + + it("returns undefined for missing key", () => { + const cache = new MemCache(10_000); + expect(cache.get("nonexistent")).toBeUndefined(); + }); + + it("overwrites existing value", () => { + const cache = new MemCache(10_000); + cache.set("key", "old"); + cache.set("key", "new"); + expect(cache.get("key")).toBe("new"); + }); + + it("works with non-string values", () => { + const cache = new MemCache(10_000); + cache.set("obj", { a: 1 }); + expect(cache.get("obj")).toEqual({ a: 1 }); + cache.set("arr", [1, 2, 3]); + expect(cache.get("arr")).toEqual([1, 2, 3]); + }); + }); + + describe("TTL expiry", () => { + it("returns undefined for expired entries", () => { + const cache = new MemCache(1000); // 1 second TTL + // Set entry with a time 2 seconds in the past + cache.set("old", "stale", Date.now() - 2000); + expect(cache.get("old")).toBeUndefined(); + }); + + it("returns value for non-expired entries", () => { + const cache = new MemCache(10_000); + cache.set("fresh", "value", Date.now()); + expect(cache.get("fresh")).toBe("value"); + }); + + it("returns stale value when allowStale is true", () => { + const cache = new MemCache(1000); + cache.set("old", "stale", Date.now() - 2000); + expect(cache.get("old", true)).toBe("stale"); + }); + + it("deletes expired entry from cache on non-stale get", () => { + const cache = new MemCache(1000); + cache.set("old", "stale", Date.now() - 2000); + // First get removes it + expect(cache.get("old")).toBeUndefined(); + // Even stale get now returns undefined because it was deleted + expect(cache.get("old", true)).toBeUndefined(); + }); + }); + + describe("has()", () => { + it("returns true for existing non-expired key", () => { + const cache = new MemCache(10_000); + cache.set("key", "val"); + expect(cache.has("key")).toBeTrue(); + }); + + it("returns false for missing key", () => { + const cache = new MemCache(10_000); + expect(cache.has("missing")).toBeFalse(); + }); + + it("returns false for expired key without stale option", () => { + const cache = new MemCache(1000); + cache.set("old", "val", Date.now() - 2000); + expect(cache.has("old")).toBeFalse(); + }); + + it("returns true for expired key with stale option", () => { + const cache = new MemCache(1000); + cache.set("old", "val", Date.now() - 2000); + expect(cache.has("old", true)).toBeTrue(); + }); + }); + + describe("getOr()", () => { + it("returns cached value if present", () => { + const cache = new MemCache(10_000); + cache.set("key", "cached"); + const result = cache.getOr("key", () => "default"); + expect(result).toBe("cached"); + }); + + it("calls defaultFunction and caches result for missing key", () => { + const cache = new MemCache(10_000); + let callCount = 0; + const factory = () => { + callCount++; + return "computed"; + }; + expect(cache.getOr("key", factory)).toBe("computed"); + expect(callCount).toBe(1); + + // Second call uses cached value + expect(cache.getOr("key", factory)).toBe("computed"); + expect(callCount).toBe(1); + }); + + it("recomputes when entry has expired", () => { + const cache = new MemCache(1000); + cache.set("key", "old", Date.now() - 2000); + const result = cache.getOr("key", () => "fresh"); + expect(result).toBe("fresh"); + }); + }); + + describe("delete()", () => { + it("removes an entry", () => { + const cache = new MemCache(10_000); + cache.set("key", "val"); + expect(cache.delete("key")).toBeTrue(); + expect(cache.get("key")).toBeUndefined(); + }); + + it("returns false for nonexistent key", () => { + const cache = new MemCache(10_000); + expect(cache.delete("nonexistent")).toBeFalse(); + }); + }); + + describe("clear()", () => { + it("removes all entries", () => { + const cache = new MemCache(10_000); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + cache.clear(); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("c")).toBeUndefined(); + }); + }); + + describe("expires()", () => { + it("returns remaining TTL for a valid entry", () => { + const cache = new MemCache(10_000); + cache.set("key", "val", Date.now()); + const remaining = cache.expires("key"); + // Should be close to 10_000 but we give some tolerance + expect(remaining).toBeGreaterThan(9_900); + expect(remaining).toBeLessThanOrEqual(10_000); + }); + + it("returns 0 for missing key", () => { + const cache = new MemCache(10_000); + expect(cache.expires("nonexistent")).toBe(0); + }); + + it("returns 0 for expired key", () => { + const cache = new MemCache(1000); + cache.set("old", "val", Date.now() - 2000); + expect(cache.expires("old")).toBe(0); + }); + }); + + describe("invalidate()", () => { + it("removes expired entries and returns their keys", () => { + const cache = new MemCache(1000); + cache.set("old1", "v1", Date.now() - 2000); + cache.set("old2", "v2", Date.now() - 3000); + cache.set("fresh", "v3", Date.now()); + + const invalidated = cache.invalidate(); + expect(invalidated).toContain("old1"); + expect(invalidated).toContain("old2"); + expect(invalidated).not.toContain("fresh"); + }); + + it("returns empty array when nothing is expired", () => { + const cache = new MemCache(10_000); + cache.set("a", 1); + cache.set("b", 2); + expect(cache.invalidate()).toEqual([]); + }); + + it("makes expired entries inaccessible", () => { + const cache = new MemCache(1000); + cache.set("old", "val", Date.now() - 2000); + cache.invalidate(); + expect(cache.get("old", true)).toBeUndefined(); + }); + }); +}); diff --git a/tests/utils/misc.test.js b/tests/utils/misc.test.js new file mode 100644 index 00000000..7297f0fb --- /dev/null +++ b/tests/utils/misc.test.js @@ -0,0 +1,128 @@ +import { env, seconds, ms, HTTPError } from "../../build/utils/misc.js"; + +describe("utils/misc", () => { + describe("env()", () => { + const savedEnv = {}; + + afterEach(() => { + for (const key of Object.keys(savedEnv)) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + }); + + it("returns value of a set env variable", () => { + savedEnv.TEST_ENV_VAR = process.env.TEST_ENV_VAR; + process.env.TEST_ENV_VAR = "hello"; + expect(env("TEST_ENV_VAR")).toBe("hello"); + }); + + it("throws when env variable is not set", () => { + savedEnv.SURELY_UNSET_VAR = process.env.SURELY_UNSET_VAR; + delete process.env.SURELY_UNSET_VAR; + expect(() => env("SURELY_UNSET_VAR")).toThrow(); + }); + + it("throws when env variable is empty string", () => { + savedEnv.EMPTY_VAR = process.env.EMPTY_VAR; + process.env.EMPTY_VAR = ""; + expect(() => env("EMPTY_VAR")).toThrow(); + }); + }); + + describe("seconds()", () => { + it("parses seconds", () => { + expect(seconds("1s")).toBe(1); + expect(seconds("30s")).toBe(30); + }); + + it("parses minutes", () => { + expect(seconds("1m")).toBe(60); + expect(seconds("2m")).toBe(120); + }); + + it("parses hours", () => { + expect(seconds("1h")).toBe(3600); + expect(seconds("2h")).toBe(7200); + }); + + it("parses days", () => { + expect(seconds("1d")).toBe(86400); + }); + + it("parses weeks", () => { + expect(seconds("1w")).toBe(604800); + }); + + it("parses fractional values", () => { + expect(seconds("1.5m")).toBe(90); + expect(seconds("0.5h")).toBe(1800); + }); + + it("allows optional space between number and unit", () => { + expect(seconds("1 m")).toBe(60); + expect(seconds("2 h")).toBe(7200); + }); + + it("is case-insensitive for units", () => { + expect(seconds("1M")).toBe(60); + expect(seconds("1H")).toBe(3600); + expect(seconds("1D")).toBe(86400); + expect(seconds("1W")).toBe(604800); + expect(seconds("1S")).toBe(1); + }); + + it("throws on invalid format", () => { + expect(() => seconds("")).toThrowError('Invalid duration format: ""'); + expect(() => seconds("abc")).toThrowError( + 'Invalid duration format: "abc"', + ); + expect(() => seconds("1x")).toThrowError( + 'Invalid duration format: "1x"', + ); + }); + + it("throws on zero value", () => { + // parseFloat("0") returns 0, which is falsy, so the guard rejects it + expect(() => seconds("0s")).toThrowError( + 'Invalid duration format: "0s"', + ); + }); + }); + + describe("ms()", () => { + it("returns milliseconds", () => { + expect(ms("1s")).toBe(1000); + expect(ms("1m")).toBe(60_000); + expect(ms("10.5s")).toBe(10_500); + }); + + it("throws on invalid format", () => { + expect(() => ms("bad")).toThrowError('Invalid duration format: "bad"'); + }); + }); + + describe("HTTPError", () => { + it("extends Error", () => { + const err = new HTTPError(404, "Not Found"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(HTTPError); + }); + + it("has statusCode and message", () => { + const err = new HTTPError(500, "Internal Server Error"); + expect(err.statusCode).toBe(500); + expect(err.message).toBe("Internal Server Error"); + }); + + it("supports optional url property", () => { + const err = new HTTPError(404, "Not Found", "https://example.com"); + expect(err.url).toBe("https://example.com"); + }); + + it("url is undefined when not provided", () => { + const err = new HTTPError(400, "Bad Request"); + expect(err.url).toBeUndefined(); + }); + }); +}); From ac3b1cd6afae545c1be3bd49d7c8fb3824fee0b6 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Mon, 27 Apr 2026 12:06:14 +1000 Subject: [PATCH 2/7] fix(tests): use try/catch for raw string throws and per-test env snapshot env() throws raw strings, not Error instances, so toThrow() can't reliably match messages. Switch to try/catch with exact string assertion. Reset savedEnv in beforeEach so each test tracks only its own keys. --- tests/utils/misc.test.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/utils/misc.test.js b/tests/utils/misc.test.js index 7297f0fb..11b4edd7 100644 --- a/tests/utils/misc.test.js +++ b/tests/utils/misc.test.js @@ -2,7 +2,11 @@ import { env, seconds, ms, HTTPError } from "../../build/utils/misc.js"; describe("utils/misc", () => { describe("env()", () => { - const savedEnv = {}; + let savedEnv; + + beforeEach(() => { + savedEnv = {}; + }); afterEach(() => { for (const key of Object.keys(savedEnv)) { @@ -20,13 +24,23 @@ describe("utils/misc", () => { it("throws when env variable is not set", () => { savedEnv.SURELY_UNSET_VAR = process.env.SURELY_UNSET_VAR; delete process.env.SURELY_UNSET_VAR; - expect(() => env("SURELY_UNSET_VAR")).toThrow(); + try { + env("SURELY_UNSET_VAR"); + fail("Expected env() to throw"); + } catch (thrown) { + expect(thrown).toBe("env variable `SURELY_UNSET_VAR` is not set."); + } }); it("throws when env variable is empty string", () => { savedEnv.EMPTY_VAR = process.env.EMPTY_VAR; process.env.EMPTY_VAR = ""; - expect(() => env("EMPTY_VAR")).toThrow(); + try { + env("EMPTY_VAR"); + fail("Expected env() to throw"); + } catch (thrown) { + expect(thrown).toBe("env variable `EMPTY_VAR` is not set."); + } }); }); From 8620d2120c1eb9fb5d49a71108c502a5d00a653e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20C=C3=A1ceres?= Date: Mon, 27 Apr 2026 18:13:34 +1000 Subject: [PATCH 3/7] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/utils/disk-cache.test.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js index bc870db0..7883f013 100644 --- a/tests/utils/disk-cache.test.js +++ b/tests/utils/disk-cache.test.js @@ -65,30 +65,41 @@ describe("utils/DiskCache", () => { describe("TTL expiry", () => { it("returns undefined for expired entries", async () => { // Use a very short TTL + const fakeNowStart = 1_000; + let fakeNow = fakeNowStart; + spyOn(Date, "now").and.callFake(() => fakeNow); + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); await cache.set("key", "value"); - // Wait briefly to ensure TTL expires - await new Promise(resolve => setTimeout(resolve, 10)); + fakeNow = fakeNowStart + 10; expect(await cache.get("key")).toBeUndefined(); }); it("returns stale value when allowStale is true", async () => { + const fakeNowStart = 2_000; + let fakeNow = fakeNowStart; + spyOn(Date, "now").and.callFake(() => fakeNow); + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); await cache.set("key", "value"); - await new Promise(resolve => setTimeout(resolve, 10)); + fakeNow = fakeNowStart + 10; expect(await cache.get("key", true)).toBe("value"); }); }); describe("invalidate()", () => { it("removes expired entries from memory and disk", async () => { + const fakeNowStart = 3_000; + let fakeNow = fakeNowStart; + spyOn(Date, "now").and.callFake(() => fakeNow); + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); await cache.set("a", 1); await cache.set("b", 2); - await new Promise(resolve => setTimeout(resolve, 10)); + fakeNow = fakeNowStart + 10; await cache.invalidate(); expect(await cache.get("a", true)).toBeUndefined(); From d71b9252b32458e41a3dce36c69310f6bacd24fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20C=C3=A1ceres?= Date: Mon, 27 Apr 2026 18:15:06 +1000 Subject: [PATCH 4/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/utils/disk-cache.test.js | 69 +++++++++++++++++++++------------- tests/utils/misc.test.js | 10 ++--- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js index 7883f013..63306c74 100644 --- a/tests/utils/disk-cache.test.js +++ b/tests/utils/disk-cache.test.js @@ -73,54 +73,71 @@ describe("utils/DiskCache", () => { await cache.set("key", "value"); fakeNow = fakeNowStart + 10; - expect(await cache.get("key")).toBeUndefined(); + describe("time-dependent expiry", () => { + let now; + + beforeEach(() => { + now = 1_000; + spyOn(Date, "now").and.callFake(() => now); }); - it("returns stale value when allowStale is true", async () => { - const fakeNowStart = 2_000; - let fakeNow = fakeNowStart; - spyOn(Date, "now").and.callFake(() => fakeNow); + describe("TTL expiry", () => { + it("returns undefined for expired entries", async () => { + // Use a very short TTL + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("key", "value"); - const cache = new DiskCache({ ttl: 1, path: "test-cache" }); - await cache.set("key", "value"); + now += 10; + expect(await cache.get("key")).toBeUndefined(); + }); - fakeNow = fakeNowStart + 10; - expect(await cache.get("key", true)).toBe("value"); - }); - }); + it("returns stale value when allowStale is true", async () => { + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("key", "value"); - describe("invalidate()", () => { - it("removes expired entries from memory and disk", async () => { - const fakeNowStart = 3_000; - let fakeNow = fakeNowStart; - spyOn(Date, "now").and.callFake(() => fakeNow); + now += 10; + expect(await cache.get("key", true)).toBe("value"); + }); + }); - const cache = new DiskCache({ ttl: 1, path: "test-cache" }); - await cache.set("a", 1); - await cache.set("b", 2); + describe("invalidate()", () => { + it("removes expired entries from memory and disk", async () => { + const cache = new DiskCache({ ttl: 1, path: "test-cache" }); + await cache.set("a", 1); + await cache.set("b", 2); - fakeNow = fakeNowStart + 10; - await cache.invalidate(); + now += 10; + await cache.invalidate(); - expect(await cache.get("a", true)).toBeUndefined(); - expect(await cache.get("b", true)).toBeUndefined(); + expect(await cache.get("a", true)).toBeUndefined(); + expect(await cache.get("b", true)).toBeUndefined(); + }); }); }); describe("path traversal prevention", () => { it("rejects keys with path traversal (..)", async () => { const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await expectAsync(cache.set("foo/../evil", "data")).toBeRejected(); + await expectAsync(cache.set("foo/../evil", "data")).toBeRejectedWithError( + Error, + /Invalid (key|path)/i + ); }); it("rejects keys with double slashes", async () => { const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await expectAsync(cache.set("foo//bar", "data")).toBeRejected(); + await expectAsync(cache.set("foo//bar", "data")).toBeRejectedWithError( + Error, + /Invalid (key|path)/i + ); }); it("rejects keys with dot segments", async () => { const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await expectAsync(cache.set("foo/./bar", "data")).toBeRejected(); + await expectAsync(cache.set("foo/./bar", "data")).toBeRejectedWithError( + Error, + /Invalid (key|path)/i + ); }); }); }); diff --git a/tests/utils/misc.test.js b/tests/utils/misc.test.js index 11b4edd7..63f2cc1e 100644 --- a/tests/utils/misc.test.js +++ b/tests/utils/misc.test.js @@ -87,19 +87,19 @@ describe("utils/misc", () => { }); it("throws on invalid format", () => { - expect(() => seconds("")).toThrowError('Invalid duration format: ""'); + expect(() => seconds("")).toThrowError("Invalid duration format: \"\""); expect(() => seconds("abc")).toThrowError( - 'Invalid duration format: "abc"', + "Invalid duration format: \"abc\"", ); expect(() => seconds("1x")).toThrowError( - 'Invalid duration format: "1x"', + "Invalid duration format: \"1x\"", ); }); it("throws on zero value", () => { // parseFloat("0") returns 0, which is falsy, so the guard rejects it expect(() => seconds("0s")).toThrowError( - 'Invalid duration format: "0s"', + "Invalid duration format: \"0s\"", ); }); }); @@ -112,7 +112,7 @@ describe("utils/misc", () => { }); it("throws on invalid format", () => { - expect(() => ms("bad")).toThrowError('Invalid duration format: "bad"'); + expect(() => ms("bad")).toThrowError("Invalid duration format: \"bad\""); }); }); From 6c2b1b9837406c14967c4fc392e74ee717b4f234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20C=C3=A1ceres?= Date: Mon, 27 Apr 2026 18:16:48 +1000 Subject: [PATCH 5/7] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/utils/mem-cache.test.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/utils/mem-cache.test.js b/tests/utils/mem-cache.test.js index 93aeb197..225928e7 100644 --- a/tests/utils/mem-cache.test.js +++ b/tests/utils/mem-cache.test.js @@ -144,12 +144,13 @@ describe("utils/MemCache", () => { describe("expires()", () => { it("returns remaining TTL for a valid entry", () => { + const fixedNow = 1_700_000_000_000; + spyOn(Date, "now").and.returnValue(fixedNow); + const cache = new MemCache(10_000); - cache.set("key", "val", Date.now()); - const remaining = cache.expires("key"); - // Should be close to 10_000 but we give some tolerance - expect(remaining).toBeGreaterThan(9_900); - expect(remaining).toBeLessThanOrEqual(10_000); + cache.set("key", "val", fixedNow); + + expect(cache.expires("key")).toBe(10_000); }); it("returns 0 for missing key", () => { From 7dbbe884a60d5b1b94db20eb87b32b00453f744f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:21:10 +0000 Subject: [PATCH 6/7] fix(disk-cache): remove malformed TTL describe block and fix path-traversal prefix bypass - Remove orphaned describe("TTL expiry") + it() block (lines 65-75) that was never closed, causing "Unexpected end of input" syntax error at test load time - Fix path-traversal prefix-bypass in DiskCache.keyToFilePath(): use `baseDir + path.sep` instead of bare `baseDir` so a key like `../test-cache-evil/pwn` cannot escape the cache directory - Add test for the prefix-bypass scenario Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/505fff65-5d3f-41b9-8488-88bfebd453bb Co-authored-by: marcoscaceres <870154+marcoscaceres@users.noreply.github.com> --- tests/utils/disk-cache.test.js | 21 ++++++++++----------- utils/disk-cache.ts | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js index 63306c74..db08871f 100644 --- a/tests/utils/disk-cache.test.js +++ b/tests/utils/disk-cache.test.js @@ -62,17 +62,6 @@ describe("utils/DiskCache", () => { expect(await cache2.get("persist")).toBe("data"); }); - describe("TTL expiry", () => { - it("returns undefined for expired entries", async () => { - // Use a very short TTL - const fakeNowStart = 1_000; - let fakeNow = fakeNowStart; - spyOn(Date, "now").and.callFake(() => fakeNow); - - const cache = new DiskCache({ ttl: 1, path: "test-cache" }); - await cache.set("key", "value"); - - fakeNow = fakeNowStart + 10; describe("time-dependent expiry", () => { let now; @@ -139,5 +128,15 @@ describe("utils/DiskCache", () => { /Invalid (key|path)/i ); }); + + it("rejects prefix-bypass traversal (../test-cache-evil)", async () => { + // A key like ../test-cache-evil/pwn resolves to a path that *starts with* + // the base directory string but escapes it (e.g. /tmp/test-cache-evil). + // The check must use baseDir + sep to prevent this bypass. + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync( + cache.set("../test-cache-evil/pwn", "data") + ).toBeRejectedWithError(Error, /Invalid (key|path)/i); + }); }); }); diff --git a/utils/disk-cache.ts b/utils/disk-cache.ts index cc4db243..ec990630 100644 --- a/utils/disk-cache.ts +++ b/utils/disk-cache.ts @@ -109,8 +109,8 @@ export class DiskCache { } const baseDir = path.join(env("DATA_DIR"), this.#path); const result = path.resolve(path.join(baseDir, `${key}.json`)); - // avoid path traversal attack - if (!result.startsWith(baseDir)) { + // avoid path traversal attack (use sep to prevent prefix bypass e.g. ../test-cache-evil) + if (!result.startsWith(baseDir + path.sep)) { throw new Error(`Invalid path: ${key}`); } return result; From 4a0bcd640fe8999fadab4f2aa0e23e0eaf82fbd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:59:03 +0000 Subject: [PATCH 7/7] fix(disk-cache-test): reset tempDir and use try/finally in afterEach - Set `tempDir = undefined` at the top of `beforeEach` so that a `mkdtemp` failure on a later run cannot accidentally reuse the previous test's directory in `afterEach` - Wrap env-var restoration in `try { } finally { rm... }` so the temp directory is always cleaned up even if the env restoration step throws Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/3c3383f4-458d-4aaf-8fc6-091cdefd4390 Co-authored-by: marcoscaceres <870154+marcoscaceres@users.noreply.github.com> --- tests/utils/disk-cache.test.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js index db08871f..5872f55d 100644 --- a/tests/utils/disk-cache.test.js +++ b/tests/utils/disk-cache.test.js @@ -8,19 +8,23 @@ describe("utils/DiskCache", () => { let originalDataDir; beforeEach(async () => { + tempDir = undefined; originalDataDir = process.env.DATA_DIR; tempDir = await mkdtemp(join(tmpdir(), "disk-cache-test-")); process.env.DATA_DIR = tempDir; }); afterEach(async () => { - if (originalDataDir !== undefined) { - process.env.DATA_DIR = originalDataDir; - } else { - delete process.env.DATA_DIR; - } - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }); + try { + if (originalDataDir !== undefined) { + process.env.DATA_DIR = originalDataDir; + } else { + delete process.env.DATA_DIR; + } + } finally { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } } });