Skip to content

Commit 8e34c8f

Browse files
test: add unit tests for crypto utility functions
1 parent 6fb6740 commit 8e34c8f

1 file changed

Lines changed: 148 additions & 0 deletions

File tree

test/crypto.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import {
3+
encryptToken,
4+
decryptToken,
5+
safeCompare,
6+
getExpectedSignature,
7+
verifyGitHubSignature,
8+
} from "../src/lib/crypto";
9+
10+
const VALID_KEY = "0".repeat(64);
11+
12+
describe("encryptToken / decryptToken", () => {
13+
beforeEach(() => {
14+
process.env.ENCRYPTION_KEY = VALID_KEY;
15+
});
16+
17+
afterEach(() => {
18+
delete process.env.ENCRYPTION_KEY;
19+
});
20+
21+
it("encrypts and decrypts a string round-trip", () => {
22+
const { encrypted, iv } = encryptToken("hello world");
23+
expect(typeof encrypted).toBe("string");
24+
expect(typeof iv).toBe("string");
25+
expect(encrypted.length).toBeGreaterThan(0);
26+
expect(iv.length).toBe(24); // 12 bytes = 24 hex chars
27+
28+
const decrypted = decryptToken(encrypted, iv);
29+
expect(decrypted).toBe("hello world");
30+
});
31+
32+
it("encrypts different strings to different ciphertexts", () => {
33+
const { encrypted: e1 } = encryptToken("alice");
34+
const { encrypted: e2 } = encryptToken("bob");
35+
expect(e1).not.toBe(e2);
36+
});
37+
38+
it("decryptToken returns null for invalid IV (wrong length)", () => {
39+
const { encrypted } = encryptToken("secret");
40+
expect(decryptToken(encrypted, "abc123")).toBeNull();
41+
});
42+
43+
it("decryptToken returns null for invalid IV (non-hex)", () => {
44+
const { encrypted } = encryptToken("secret");
45+
expect(decryptToken(encrypted, "g".repeat(24))).toBeNull();
46+
});
47+
48+
it("decryptToken returns null for tampered ciphertext", () => {
49+
const { encrypted, iv } = encryptToken("secret");
50+
// Flip one hex character to corrupt the ciphertext
51+
const corrupted = encrypted.slice(0, -1) + (encrypted.at(-1) === "0" ? "1" : "0");
52+
expect(decryptToken(corrupted, iv)).toBeNull();
53+
});
54+
55+
it("decryptToken returns null for missing ENCRYPTION_KEY", () => {
56+
delete process.env.ENCRYPTION_KEY;
57+
expect(decryptToken("aabbcc", "dd")).toBeNull();
58+
});
59+
60+
it("decryptToken returns null for wrong key", () => {
61+
const { encrypted, iv } = encryptToken("secret");
62+
process.env.ENCRYPTION_KEY = "f".repeat(64);
63+
expect(decryptToken(encrypted, iv)).toBeNull();
64+
});
65+
});
66+
67+
describe("safeCompare", () => {
68+
it("returns true for identical strings", () => {
69+
expect(safeCompare("hello", "hello")).toBe(true);
70+
});
71+
72+
it("returns false for strings of different length", () => {
73+
expect(safeCompare("hello", "hell")).toBe(false);
74+
expect(safeCompare("a", "abc")).toBe(false);
75+
});
76+
77+
it("returns false for same-length different strings", () => {
78+
expect(safeCompare("hello", "hxllo")).toBe(false);
79+
});
80+
81+
it("handles empty strings", () => {
82+
expect(safeCompare("", "")).toBe(true);
83+
expect(safeCompare("", "a")).toBe(false);
84+
});
85+
86+
it("handles unicode strings", () => {
87+
expect(safeCompare("hello world", "hello world")).toBe(true);
88+
expect(safeCompare("hello world", "hello worlD")).toBe(false);
89+
});
90+
});
91+
92+
describe("getExpectedSignature", () => {
93+
it("returns a sha256= prefixed hex string", () => {
94+
const sig = getExpectedSignature("secret", "body");
95+
expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
96+
});
97+
98+
it("is deterministic for same secret and body", () => {
99+
const sig1 = getExpectedSignature("secret", "body");
100+
const sig2 = getExpectedSignature("secret", "body");
101+
expect(sig1).toBe(sig2);
102+
});
103+
104+
it("produces different signatures for different secrets", () => {
105+
const sig1 = getExpectedSignature("secret1", "body");
106+
const sig2 = getExpectedSignature("secret2", "body");
107+
expect(sig1).not.toBe(sig2);
108+
});
109+
110+
it("produces different signatures for different bodies", () => {
111+
const sig1 = getExpectedSignature("secret", "body1");
112+
const sig2 = getExpectedSignature("secret", "body2");
113+
expect(sig1).not.toBe(sig2);
114+
});
115+
});
116+
117+
describe("verifyGitHubSignature", () => {
118+
const secret = "webhook-secret";
119+
const body = '{"action":"push"}';
120+
121+
it("returns true for a valid signature", () => {
122+
const sig = getExpectedSignature(secret, body);
123+
expect(verifyGitHubSignature(body, sig, secret)).toBe(true);
124+
});
125+
126+
it("returns false for a null signature", () => {
127+
expect(verifyGitHubSignature(body, null, secret)).toBe(false);
128+
});
129+
130+
it("returns false for an undefined signature", () => {
131+
expect(verifyGitHubSignature(body, undefined as unknown as string, secret)).toBe(false);
132+
});
133+
134+
it("returns false for a signature without sha256= prefix", () => {
135+
const bad = "abc123";
136+
expect(verifyGitHubSignature(body, bad, secret)).toBe(false);
137+
});
138+
139+
it("returns false for a wrong secret", () => {
140+
const sig = getExpectedSignature(secret, body);
141+
expect(verifyGitHubSignature(body, sig, "wrong-secret")).toBe(false);
142+
});
143+
144+
it("returns false for a tampered body", () => {
145+
const sig = getExpectedSignature(secret, body);
146+
expect(verifyGitHubSignature('{"action":"delete"}', sig, secret)).toBe(false);
147+
});
148+
});

0 commit comments

Comments
 (0)