syntaxai/tdd.md · main · src / b32_session.test.ts

b32_session.test.ts 180 lines · 6282 bytes raw
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);
  });
});