293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
createContext,
|
|
rateLimit,
|
|
requestLogger,
|
|
TTLCache,
|
|
cacheControl,
|
|
withCacheControl,
|
|
etag,
|
|
notModified,
|
|
saveUpload,
|
|
collectUploads,
|
|
sanitizeFilename,
|
|
UploadError,
|
|
setSessionBackend,
|
|
loadSession,
|
|
type SessionBackend,
|
|
type SessionEntry,
|
|
type AsyncSessionBackend,
|
|
type RateLimitStore,
|
|
} from "../src/index.ts";
|
|
|
|
function memoryBackend(): SessionBackend {
|
|
const map = new Map<string, SessionEntry>();
|
|
return {
|
|
get: (id) => map.get(id),
|
|
set: (id, e) => void map.set(id, e),
|
|
delete: (id) => void map.delete(id),
|
|
_map: map,
|
|
} as SessionBackend & { _map: Map<string, SessionEntry> };
|
|
}
|
|
|
|
function ctx(path = "/", headers: Record<string, string> = {}) {
|
|
const url = new URL(`http://x${path}`);
|
|
return createContext(new Request(url, { headers }), url);
|
|
}
|
|
|
|
// --- rate limiting ---------------------------------------------------------
|
|
|
|
test("rateLimit (trustProxy) allows up to max then 429 with headers", async () => {
|
|
const mw = rateLimit({ max: 2, windowMs: 60_000, trustProxy: true });
|
|
const ok = () => new Response("ok");
|
|
const key = { "x-forwarded-for": "1.1.1.1" };
|
|
|
|
const r1 = await mw(ctx("/", key), ok);
|
|
const r2 = await mw(ctx("/", key), ok);
|
|
const r3 = await mw(ctx("/", key), ok);
|
|
expect(r1.status).toBe(200);
|
|
expect(r1.headers.get("RateLimit-Remaining")).toBe("1");
|
|
expect(r2.status).toBe(200);
|
|
expect(r2.headers.get("RateLimit-Remaining")).toBe("0");
|
|
expect(r3.status).toBe(429);
|
|
expect(r3.headers.get("retry-after")).toBeTruthy();
|
|
});
|
|
|
|
test("rateLimit (trustProxy) buckets are independent per key", async () => {
|
|
const mw = rateLimit({ max: 1, windowMs: 60_000, trustProxy: true });
|
|
const ok = () => new Response("ok");
|
|
const a = await mw(ctx("/", { "x-forwarded-for": "2.2.2.2" }), ok);
|
|
const b = await mw(ctx("/", { "x-forwarded-for": "3.3.3.3" }), ok);
|
|
expect(a.status).toBe(200);
|
|
expect(b.status).toBe(200);
|
|
});
|
|
|
|
test("setSessionBackend routes session data through a custom backend", () => {
|
|
const backend = memoryBackend() as SessionBackend & { _map: Map<string, SessionEntry> };
|
|
setSessionBackend(backend);
|
|
try {
|
|
const c = ctx("/");
|
|
c.session.set("k", "v");
|
|
const id = c.session.id();
|
|
expect(backend._map.has(id)).toBe(true);
|
|
expect(backend._map.get(id)!.data).toEqual({ k: "v" });
|
|
} finally {
|
|
setSessionBackend(memoryBackend()); // restore an equivalent for other tests
|
|
}
|
|
});
|
|
|
|
test("rateLimit accepts a custom (shared) store", async () => {
|
|
const hits: string[] = [];
|
|
const store: RateLimitStore = {
|
|
hit(key, windowMs, now) {
|
|
hits.push(key);
|
|
return { count: hits.filter((k) => k === key).length, resetAt: now + windowMs };
|
|
},
|
|
};
|
|
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
|
const ok = () => new Response("ok");
|
|
const key = { "x-forwarded-for": "5.5.5.5" };
|
|
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
|
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
|
expect(hits.length).toBe(2); // both requests went through the injected store
|
|
});
|
|
|
|
test("rateLimit awaits an ASYNC store (e.g. Redis)", async () => {
|
|
const counts = new Map<string, number>();
|
|
const store: RateLimitStore = {
|
|
async hit(key, windowMs, now) {
|
|
const n = (counts.get(key) ?? 0) + 1;
|
|
counts.set(key, n);
|
|
return { count: n, resetAt: now + windowMs };
|
|
},
|
|
};
|
|
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
|
const ok = () => new Response("ok");
|
|
const key = { "x-forwarded-for": "7.7.7.7" };
|
|
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
|
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
|
});
|
|
|
|
test("loadSession persists a session through an ASYNC backend across requests", async () => {
|
|
const kv = new Map<string, SessionEntry>();
|
|
const backend: AsyncSessionBackend = {
|
|
load: async (id) => kv.get(id),
|
|
save: async (id, entry) => void kv.set(id, entry),
|
|
destroy: async (id) => void kv.delete(id),
|
|
};
|
|
const mw = loadSession(backend);
|
|
|
|
// Request 1: write a value, capture the issued session id.
|
|
const c1 = ctx("/");
|
|
await mw(c1, () => {
|
|
c1.session.set("hits", 1);
|
|
return new Response("ok");
|
|
});
|
|
const sid = c1.cookies.get("wrnexus.sid")!;
|
|
expect(sid).toBeTruthy();
|
|
expect(kv.has(sid)).toBe(true); // saved to the async backend
|
|
|
|
// Request 2: same cookie → session loads from the backend.
|
|
const c2 = ctx("/", { cookie: `wrnexus.sid=${sid}` });
|
|
let seen: unknown;
|
|
await mw(c2, () => {
|
|
seen = c2.session.get("hits");
|
|
return new Response("ok");
|
|
});
|
|
expect(seen).toBe(1);
|
|
});
|
|
|
|
test("rateLimit default keys on the non-spoofable peer IP, not XFF headers", async () => {
|
|
const mw = rateLimit({ max: 1, windowMs: 60_000 });
|
|
const ok = () => new Response("ok");
|
|
// Same peer IP, different spoofed XFF → still one bucket (XFF ignored).
|
|
const c1 = ctx("/", { "x-forwarded-for": "9.9.9.9" });
|
|
c1.ip = "10.0.0.1";
|
|
const c2 = ctx("/", { "x-forwarded-for": "8.8.8.8" });
|
|
c2.ip = "10.0.0.1";
|
|
expect((await mw(c1, ok)).status).toBe(200);
|
|
expect((await mw(c2, ok)).status).toBe(429);
|
|
});
|
|
|
|
test("rateLimit validates its in-memory key bound", () => {
|
|
expect(() => rateLimit({ maxKeys: 0 })).toThrow("maxKeys");
|
|
});
|
|
|
|
// --- request logging -------------------------------------------------------
|
|
|
|
test("requestLogger emits a structured record with duration and id", async () => {
|
|
const records: string[] = [];
|
|
let t = 1000;
|
|
const mw = requestLogger({
|
|
format: "json",
|
|
sink: (line) => records.push(line),
|
|
now: () => (t += 5),
|
|
});
|
|
const res = await mw(ctx("/api/users"), () => new Response("x", { status: 201 }));
|
|
expect(res.status).toBe(201);
|
|
const rec = JSON.parse(records[0]!);
|
|
expect(rec.method).toBe("GET");
|
|
expect(rec.path).toBe("/api/users");
|
|
expect(rec.status).toBe(201);
|
|
expect(rec.durationMs).toBeGreaterThanOrEqual(0);
|
|
expect(rec.id).toBeTruthy();
|
|
});
|
|
|
|
test("requestLogger logs status 500 when the handler throws", async () => {
|
|
const records: string[] = [];
|
|
const mw = requestLogger({ format: "json", sink: (l) => records.push(l) });
|
|
await expect(
|
|
mw(ctx("/boom"), () => {
|
|
throw new Error("nope");
|
|
}),
|
|
).rejects.toThrow("nope");
|
|
expect(JSON.parse(records[0]!).status).toBe(500);
|
|
});
|
|
|
|
// --- caching ---------------------------------------------------------------
|
|
|
|
test("TTLCache getOrLoad caches until expiry", async () => {
|
|
const cache = new TTLCache<number>(60_000);
|
|
let calls = 0;
|
|
const load = () => {
|
|
calls++;
|
|
return 42;
|
|
};
|
|
expect(await cache.getOrLoad("k", load)).toBe(42);
|
|
expect(await cache.getOrLoad("k", load)).toBe(42);
|
|
expect(calls).toBe(1);
|
|
cache.delete("k");
|
|
expect(await cache.getOrLoad("k", load)).toBe(42);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
test("TTLCache coalesces concurrent loads for the same key", async () => {
|
|
const cache = new TTLCache<number>();
|
|
let calls = 0;
|
|
const loader = async () => {
|
|
calls++;
|
|
await Promise.resolve();
|
|
return 7;
|
|
};
|
|
expect(await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)])).toEqual([
|
|
7, 7,
|
|
]);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
test("TTLCache does not let an old in-flight load overwrite set, delete, or clear", async () => {
|
|
const cache = new TTLCache<number>();
|
|
let release!: (value: number) => void;
|
|
const loading = cache.getOrLoad("x", () => new Promise<number>((resolve) => (release = resolve)));
|
|
await Promise.resolve();
|
|
cache.set("x", 9);
|
|
release(1);
|
|
expect(await loading).toBe(1);
|
|
expect(cache.get("x")).toBe(9);
|
|
|
|
let releaseClear!: (value: number) => void;
|
|
const clearing = cache.getOrLoad(
|
|
"y",
|
|
() => new Promise<number>((resolve) => (releaseClear = resolve)),
|
|
);
|
|
await Promise.resolve();
|
|
cache.clear();
|
|
releaseClear(2);
|
|
await clearing;
|
|
expect(cache.get("y")).toBeUndefined();
|
|
});
|
|
|
|
test("cacheControl builds directives; no-store wins", () => {
|
|
expect(cacheControl({ maxAge: 60, sMaxAge: 120 })).toBe("public, max-age=60, s-maxage=120");
|
|
expect(cacheControl({ private: true, noCache: true })).toBe("private, no-cache");
|
|
expect(cacheControl({ noStore: true, maxAge: 99 })).toBe("no-store");
|
|
const res = withCacheControl(new Response("x"), { maxAge: 30, immutable: true });
|
|
expect(res.headers.get("Cache-Control")).toBe("public, max-age=30, immutable");
|
|
});
|
|
|
|
test("etag + notModified drive conditional requests", () => {
|
|
const tag = etag("hello world");
|
|
expect(tag).toMatch(/^W\/"/);
|
|
expect(etag("hello world")).toBe(tag); // stable
|
|
expect(etag("different")).not.toBe(tag);
|
|
const req = new Request("http://x", { headers: { "if-none-match": tag } });
|
|
expect(notModified(req, tag)).toBe(true);
|
|
expect(notModified(new Request("http://x"), tag)).toBe(false);
|
|
});
|
|
|
|
// --- uploads ---------------------------------------------------------------
|
|
|
|
test("sanitizeFilename strips traversal and separators", () => {
|
|
const s = sanitizeFilename("../../etc/passwd");
|
|
expect(s).not.toContain("/");
|
|
expect(s).not.toContain("..");
|
|
expect(s).toContain("passwd");
|
|
expect(sanitizeFilename("a/b\\c.png")).toBe("a_b_c.png");
|
|
expect(sanitizeFilename("")).toBe("upload");
|
|
});
|
|
|
|
test("saveUpload writes a validated file and enforces limits", async () => {
|
|
const dir = join(tmpdir(), "wire-upload-test");
|
|
const file = new File(["hello upload"], "note.txt", { type: "text/plain" });
|
|
|
|
const saved = await saveUpload(file, { dir, allowedTypes: ["text/plain", ".txt"] });
|
|
expect(saved.filename).toBe("note.txt");
|
|
expect(saved.size).toBe(12);
|
|
expect(existsSync(saved.path)).toBe(true);
|
|
|
|
await expect(saveUpload(file, { dir, maxBytes: 4 })).rejects.toThrow(UploadError);
|
|
await expect(saveUpload(file, { dir, allowedTypes: ["image/png"] })).rejects.toThrow(UploadError);
|
|
});
|
|
|
|
test("collectUploads returns only non-empty File fields", async () => {
|
|
const form = new FormData();
|
|
form.append("name", "ada");
|
|
form.append("avatar", new File(["img"], "a.png", { type: "image/png" }));
|
|
const uploads = collectUploads(form);
|
|
expect(uploads.length).toBe(1);
|
|
expect(uploads[0]!.field).toBe("avatar");
|
|
});
|