syntaxai/tdd.md · main · src / b32_session.test.ts
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);
});
});