1import { describe, expect, it } from "vitest"; 2import { decodeToken, parseTtl, signToken, TokenError, verifyToken } from "../jwt.js"; 3 4const SECRET = "unit-test-secret-0123456789"; 5 6describe("jwt (hand-rolled HS256)", () => { 7 it("signs and verifies a roundtrip with claims intact", () => { 8 const token = signToken({ sub: "usr_123", workspaces: ["wks_1", "wks_2"], jti: "at_1" }, "15m", SECRET); 9 expect(token.split(".").length).toBe(3); 10 const claims = verifyToken(token, SECRET); 11 expect(claims.sub).toBe("usr_123"); 12 expect(claims.workspaces).toEqual(["wks_1", "wks_2"]); 13 expect(claims.jti).toBe("at_1"); 14 expect(typeof claims.iat).toBe("number"); 15 expect(typeof claims.exp).toBe("number"); 16 expect(claims.exp).toBe(claims.iat! + 15 * 60); 17 }); 18 19 it("rejects a tampered payload", () => { 20 const token = signToken({ sub: "usr_1" }, "15m", SECRET); 21 const [h, b, s] = token.split("."); 22 const forged = Buffer.from(JSON.stringify({ sub: "usr_evil", iat: 1, exp: 9999999999 })).toString("base64url"); 23 expect(() => verifyToken(`${h}.${forged}.${s}`, SECRET)).toThrowError(TokenError); 24 try { 25 verifyToken(`${h}.${forged}.${s}`, SECRET); 26 } catch (err) { 27 expect((err as TokenError).code).toBe("INVALID_TOKEN"); 28 } 29 }); 30 31 it("rejects a tampered signature", () => { 32 const token = signToken({ sub: "usr_1" }, "15m", SECRET); 33 const [h, b, s] = token.split("."); 34 const flipped = (s[0] === "A" ? "B" : "A") + s.slice(1); 35 expect(() => verifyToken(`${h}.${b}.${flipped}`, SECRET)).toThrowError(TokenError); 36 }); 37 38 it("rejects a token signed with a different key", () => { 39 const token = signToken({ sub: "usr_1" }, "15m", SECRET); 40 expect(() => verifyToken(token, "other-secret-0123456789abc")).toThrowError(TokenError); 41 }); 42 43 it("reports TOKEN_EXPIRED for expired tokens", () => { 44 const now = Math.floor(Date.now() / 1000); 45 const token = signToken({ sub: "usr_1", iat: now - 3600, exp: now - 60 }, 60, SECRET); 46 try { 47 verifyToken(token, SECRET); 48 expect.unreachable("should have thrown"); 49 } catch (err) { 50 expect(err).toBeInstanceOf(TokenError); 51 expect((err as TokenError).code).toBe("TOKEN_EXPIRED"); 52 } 53 }); 54 55 it("rejects malformed tokens", () => { 56 expect(() => verifyToken("garbage", SECRET)).toThrowError(TokenError); 57 expect(() => verifyToken("a.b", SECRET)).toThrowError(TokenError); 58 expect(() => verifyToken("", SECRET)).toThrowError(TokenError); 59 }); 60 61 it("rejects non-HS256 algorithm headers (alg confusion guard)", () => { 62 const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); 63 const body = Buffer.from(JSON.stringify({ sub: "usr_1", iat: 1, exp: 9999999999 })).toString("base64url"); 64 expect(() => verifyToken(`${header}.${body}.`, SECRET)).toThrowError(TokenError); 65 }); 66 67 it("decodeToken returns claims without verifying (and null on garbage)", () => { 68 const token = signToken({ sub: "usr_9" }, "15m", SECRET); 69 expect(decodeToken(token)?.sub).toBe("usr_9"); 70 expect(decodeToken("nope")).toBeNull(); 71 }); 72 73 it("parseTtl handles all units", () => { 74 expect(parseTtl("15m")).toBe(900); 75 expect(parseTtl("30d")).toBe(30 * 86400); 76 expect(parseTtl("2h")).toBe(7200); 77 expect(parseTtl("90s")).toBe(90); 78 expect(parseTtl("2000ms")).toBe(2); 79 expect(() => parseTtl("soon")).toThrow(); 80 }); 81}); 82