diff --git a/tests/utils/disk-cache.test.js b/tests/utils/disk-cache.test.js new file mode 100644 index 00000000..5872f55d --- /dev/null +++ b/tests/utils/disk-cache.test.js @@ -0,0 +1,146 @@ +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 () => { + tempDir = undefined; + originalDataDir = process.env.DATA_DIR; + tempDir = await mkdtemp(join(tmpdir(), "disk-cache-test-")); + process.env.DATA_DIR = tempDir; + }); + + afterEach(async () => { + 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 }); + } + } + }); + + 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("time-dependent expiry", () => { + let now; + + beforeEach(() => { + now = 1_000; + spyOn(Date, "now").and.callFake(() => now); + }); + + 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"); + + now += 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"); + + now += 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); + + now += 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")).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")).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")).toBeRejectedWithError( + Error, + /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/tests/utils/mem-cache.test.js b/tests/utils/mem-cache.test.js new file mode 100644 index 00000000..225928e7 --- /dev/null +++ b/tests/utils/mem-cache.test.js @@ -0,0 +1,195 @@ +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 fixedNow = 1_700_000_000_000; + spyOn(Date, "now").and.returnValue(fixedNow); + + const cache = new MemCache(10_000); + cache.set("key", "val", fixedNow); + + expect(cache.expires("key")).toBe(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 index 94f4c2e4..ebf4899e 100644 --- a/tests/utils/misc.test.js +++ b/tests/utils/misc.test.js @@ -1,6 +1,121 @@ -import { getErrnoCode } from "../../build/utils/misc.js"; +import { env, seconds, ms, HTTPError, getErrnoCode } from "../../build/utils/misc.js"; describe("utils/misc", () => { + describe("env()", () => { + let savedEnv; + + beforeEach(() => { + 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; + 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 = ""; + try { + env("EMPTY_VAR"); + fail("Expected env() to throw"); + } catch (thrown) { + expect(thrown).toBe("env variable `EMPTY_VAR` is not set."); + } + }); + }); + + 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("getErrnoCode", () => { it("returns the string code from an Error with a code property", () => { const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); @@ -35,4 +150,28 @@ describe("utils/misc", () => { expect(getErrnoCode(404)).toBeUndefined(); }); }); + + 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(); + }); + }); }); diff --git a/utils/disk-cache.ts b/utils/disk-cache.ts index 8237e91a..d7058ec1 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;