import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { parseCookies, timingSafeEqual, hmacSha256Hex, sessionCookieHeader, randomHex, signSession, verifySession, SESSION_TTL_SEC, } from "./b32_session.ts"; describe("c32_session — parseCookies", () => { test("empty / null header returns an empty object", () => { expect(parseCookies(null)).toEqual({}); expect(parseCookies("")).toEqual({}); }); test("parses a single name=value pair", () => { expect(parseCookies("tdd_session=abc")).toEqual({ tdd_session: "abc" }); }); test("parses multiple pairs separated by `;`", () => { const out = parseCookies("a=1; b=2; c=3"); expect(out).toEqual({ a: "1", b: "2", c: "3" }); }); test("strips surrounding whitespace from name and value", () => { expect(parseCookies(" k = v ")).toEqual({ k: "v" }); }); test("url-decodes values", () => { expect(parseCookies("path=%2Ffoo%2Fbar")).toEqual({ path: "/foo/bar" }); }); test("ignores entries that have no `=` separator", () => { expect(parseCookies("malformed; ok=yes")).toEqual({ ok: "yes" }); }); }); describe("c32_session — timingSafeEqual", () => { test("returns true for identical strings", () => { expect(timingSafeEqual("hello", "hello")).toBe(true); }); test("returns false for different strings of the same length", () => { expect(timingSafeEqual("hello", "world")).toBe(false); }); test("returns false when lengths differ — early exit", () => { expect(timingSafeEqual("a", "ab")).toBe(false); }); test("returns true for two empty strings", () => { expect(timingSafeEqual("", "")).toBe(true); }); }); describe("c32_session — hmacSha256Hex", () => { test("is deterministic for a fixed (secret, body) pair", async () => { const a = await hmacSha256Hex("s3cret", "payload"); const b = await hmacSha256Hex("s3cret", "payload"); expect(a).toBe(b); }); test("returns a 64-char lowercase hex string (SHA-256 hex length)", async () => { const sig = await hmacSha256Hex("k", "v"); expect(sig).toMatch(/^[0-9a-f]{64}$/); }); test("a different secret produces a different signature for the same body", async () => { const a = await hmacSha256Hex("secret-a", "payload"); const b = await hmacSha256Hex("secret-b", "payload"); expect(a).not.toBe(b); }); test("a different body produces a different signature for the same secret", async () => { const a = await hmacSha256Hex("k", "body-a"); const b = await hmacSha256Hex("k", "body-b"); expect(a).not.toBe(b); }); }); describe("c32_session — sessionCookieHeader", () => { test("formats the canonical attributes", () => { const h = sessionCookieHeader("token-x", 3600); expect(h).toContain("tdd_session=token-x"); expect(h).toContain("Path=/"); expect(h).toContain("HttpOnly"); expect(h).toContain("Secure"); expect(h).toContain("SameSite=Lax"); expect(h).toContain("Max-Age=3600"); }); test("zero max-age (logout) still emits Max-Age=0", () => { expect(sessionCookieHeader("", 0)).toContain("Max-Age=0"); }); }); describe("c32_session — randomHex", () => { test("returns a hex string of 2 × bytes characters", () => { expect(randomHex(8)).toMatch(/^[0-9a-f]{16}$/); expect(randomHex(16)).toMatch(/^[0-9a-f]{32}$/); }); test("successive calls produce distinct values", () => { expect(randomHex(16)).not.toBe(randomHex(16)); }); }); describe("c32_session — signSession / verifySession round-trip", () => { // The signer reads SESSION_SECRET (or WEBHOOK_SECRET) from the env. // Set a fixed value before the tests run so both sides hash with the // same key. beforeAll/afterAll, not bare describe-body, because the // body runs at registration time while tests run async — restoration // there would happen *before* any test executes. let original: string | undefined; beforeAll(() => { original = process.env.SESSION_SECRET; process.env.SESSION_SECRET = "test-secret-do-not-use-in-prod"; }); afterAll(() => { if (original === undefined) { delete process.env.SESSION_SECRET; } else { process.env.SESSION_SECRET = original; } }); test("signSession produces a 3-part cookie of `name.exp.sig`", async () => { const cookie = await signSession("alice"); const parts = cookie.split("."); expect(parts.length).toBe(3); expect(parts[0]).toBe("alice"); expect(Number(parts[1])).toBeGreaterThan(Math.floor(Date.now() / 1000)); }); test("verifySession round-trips a freshly signed cookie back to the username", async () => { const cookie = await signSession("bob"); const username = await verifySession(cookie); expect(username).toBe("bob"); }); test("verifySession rejects a cookie with a forged signature", async () => { const cookie = await signSession("eve"); // Flip the LAST sig char to something *guaranteed* different — // a fixed `replace(/.$/, "0")` collides when the original char is // already "0" (~1 in 16 runs). Detect the original and flip to // a hex digit it can never be. const lastChar = cookie.slice(-1); const tampered = cookie.slice(0, -1) + (lastChar === "f" ? "0" : "f"); expect(tampered).not.toBe(cookie); const result = await verifySession(tampered); expect(result).toBeNull(); }); test("verifySession rejects a cookie that's not three parts", async () => { expect(await verifySession("just-one-part")).toBeNull(); expect(await verifySession("two.parts")).toBeNull(); }); test("verifySession rejects a cookie whose expiry is in the past", async () => { // Hand-roll a cookie with an `exp` that's already passed; sign with // the same secret so the HMAC matches but the time-window check // fails. const username = "carol"; const exp = Math.floor(Date.now() / 1000) - 60; const sig = await hmacSha256Hex(process.env.SESSION_SECRET!, `${username}.${exp}`); const cookie = `${username}.${exp}.${sig}`; expect(await verifySession(cookie)).toBeNull(); }); }); describe("c32_session — exports", () => { test("SESSION_TTL_SEC is a positive integer (30 days)", () => { expect(SESSION_TTL_SEC).toBe(30 * 24 * 60 * 60); }); });