Skip to content
146 changes: 146 additions & 0 deletions tests/utils/disk-cache.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
});
Comment thread
marcoscaceres marked this conversation as resolved.

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);
});
});
Comment thread
marcoscaceres marked this conversation as resolved.
});
195 changes: 195 additions & 0 deletions tests/utils/mem-cache.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading