Files
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

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(), "wrn-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");
});