Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions test/crypto-edge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
const TEST_KEY = "a".repeat(64);
describe("decryptTokenEdge", () => {
beforeEach(() => { process.env.ENCRYPTION_KEY = TEST_KEY; });
it("returns null when ENCRYPTION_KEY is missing", async () => {
delete process.env.ENCRYPTION_KEY;
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("deadbeef", "a".repeat(24))).toBeNull();
});
it("returns null when ENCRYPTION_KEY is invalid format", async () => {
process.env.ENCRYPTION_KEY = "short";
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("deadbeef", "a".repeat(24))).toBeNull();
});
it("returns null for non-hex encrypted string", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("not-hex!", "a".repeat(24))).toBeNull();
});
it("returns null for odd-length encrypted string", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("deadbeef", "a".repeat(24))).toBeNull();
});
it("returns null for non-hex iv", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("a".repeat(64), "not-hex!")).toBeNull();
});
it("returns null when iv is not 24 chars", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("a".repeat(64), "a".repeat(20))).toBeNull();
});
it("returns null for tampered ciphertext", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
const { encryptToken } = await import("../src/lib/crypto");
const { encrypted, iv } = encryptToken("hello world");
expect(await decryptTokenEdge(encrypted.slice(0,-2)+"ff", iv)).toBeNull();
});
it("returns null for hex with invalid characters", async () => {
const { decryptTokenEdge } = await import("../src/lib/crypto-edge");
expect(await decryptTokenEdge("g".repeat(64), "a".repeat(24))).toBeNull();
});
});
53 changes: 53 additions & 0 deletions test/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach } from "vitest";
import { encryptToken, decryptToken, safeCompare, getExpectedSignature, verifyGitHubSignature } from "../src/lib/crypto";
const TEST_KEY = "a".repeat(64);
describe("encryptToken / decryptToken", () => {
beforeEach(() => { process.env.ENCRYPTION_KEY = TEST_KEY; });
it("roundtrips a simple string", () => {
const { encrypted, iv } = encryptToken("hello world");
expect(typeof encrypted).toBe("string"); expect(typeof iv).toBe("string"); expect(iv.length).toBe(24);
expect(decryptToken(encrypted, iv)).toBe("hello world");
});
it("roundtrips unicode string", () => {
const { encrypted, iv } = encryptToken("hello, world! with emoji");
expect(decryptToken(encrypted, iv)).toBe("hello, world! with emoji");
});
it("returns null for wrong iv length", () => { const { encrypted } = encryptToken("hello"); expect(decryptToken(encrypted, "a".repeat(20))).toBeNull(); });
it("returns null for tampered ciphertext", () => { const { encrypted, iv } = encryptToken("hello"); expect(decryptToken(encrypted.slice(0,-2)+"ff", iv)).toBeNull(); });
it("throws when ENCRYPTION_KEY is not set", () => { delete process.env.ENCRYPTION_KEY; expect(() => encryptToken("hello")).toThrow(); });
it("throws when ENCRYPTION_KEY is invalid format", () => { process.env.ENCRYPTION_KEY = "short"; expect(() => encryptToken("hello")).toThrow(); });
it("produces different ciphertexts for same plaintext (random IV)", () => {
const { encrypted: e1, iv: iv1 } = encryptToken("hello");
const { encrypted: e2, iv: iv2 } = encryptToken("hello");
expect(e1).not.toBe(e2); expect(iv1).not.toBe(iv2);
});
});
describe("safeCompare", () => {
it("returns true for equal strings", () => { expect(safeCompare("hello","hello")).toBe(true); });
it("returns false for different strings", () => { expect(safeCompare("hello","world")).toBe(false); });
it("returns false for strings of different length", () => { expect(safeCompare("hello","hi")).toBe(false); });
it("handles empty strings", () => { expect(safeCompare("","")).toBe(true); expect(safeCompare("","a")).toBe(false); });
it("returns boolean", () => { expect(typeof safeCompare("abc","abc")).toBe("boolean"); });
});
describe("getExpectedSignature", () => {
it("returns sha256=... format", () => { expect(getExpectedSignature("secret","body").startsWith("sha256=")).toBe(true); });
it("produces consistent output for same inputs", () => { expect(getExpectedSignature("secret","body")).toBe(getExpectedSignature("secret","body")); });
it("produces different output for different secrets", () => { expect(getExpectedSignature("secret1","body")).not.toBe(getExpectedSignature("secret2","body")); });
it("produces different output for different bodies", () => { expect(getExpectedSignature("secret","body1")).not.toBe(getExpectedSignature("secret","body2")); });
});
describe("verifyGitHubSignature", () => {
it("returns true for valid signature", () => {
const body = '{"action":"opened"}'; const secret = "mywebhooksecret";
expect(verifyGitHubSignature(body, getExpectedSignature(secret,body), secret)).toBe(true);
});
it("returns false for wrong secret", () => {
const body = '{"action":"opened"}';
expect(verifyGitHubSignature(body, getExpectedSignature("secret1",body), "secret2")).toBe(false);
});
it("returns false for tampered body", () => { expect(verifyGitHubSignature("tampered", getExpectedSignature("secret","original"), "secret")).toBe(false); });
it("returns false for null signature", () => { expect(verifyGitHubSignature("body", null, "secret")).toBe(false); });
it("returns false for signature without sha256= prefix", () => {
const sig = getExpectedSignature("secret","body").replace("sha256=","");
expect(verifyGitHubSignature("body", sig, "secret")).toBe(false);
});
});
40 changes: 40 additions & 0 deletions test/goals-share.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { getGoalProgressPercent, buildPublicGoalSharePath, buildPublicGoalShareUrl } from "../src/lib/goals/share";
describe("getGoalProgressPercent", () => {
it("returns 0 for zero target", () => { expect(getGoalProgressPercent(5, 0)).toBe(0); });
it("returns 0 for negative target", () => { expect(getGoalProgressPercent(5, -10)).toBe(0); });
it("returns 0 when current is negative", () => { expect(getGoalProgressPercent(-5, 10)).toBe(0); });
it("returns 0 when current is NaN", () => { expect(getGoalProgressPercent(NaN, 10)).toBe(0); });
it("returns 0 when target is NaN", () => { expect(getGoalProgressPercent(5, NaN)).toBe(0); });
it("returns 0 when target is Infinity", () => { expect(getGoalProgressPercent(5, Infinity)).toBe(0); });
it("returns 0 when current is Infinity", () => { expect(getGoalProgressPercent(Infinity, 10)).toBe(0); });
it("returns correct percentage for simple values", () => {
expect(getGoalProgressPercent(5, 10)).toBe(50);
expect(getGoalProgressPercent(1, 3)).toBe(33);
expect(getGoalProgressPercent(3, 4)).toBe(75);
});
it("rounds to nearest integer", () => {
expect(getGoalProgressPercent(1, 6)).toBe(17);
expect(getGoalProgressPercent(2, 3)).toBe(67);
});
it("caps at 100", () => {
expect(getGoalProgressPercent(15, 10)).toBe(100);
expect(getGoalProgressPercent(1000, 10)).toBe(100);
});
it("floors at 0", () => { expect(getGoalProgressPercent(-50, 10)).toBe(0); });
});
describe("buildPublicGoalSharePath", () => {
it("builds correct path for normal inputs", () => { expect(buildPublicGoalSharePath("alice", "goal-123")).toBe("/u/alice/goals/goal-123"); });
it("encodes username with special characters", () => {
expect(buildPublicGoalSharePath("alice bob", "goal-123")).toBe("/u/alice%20bob/goals/goal-123");
expect(buildPublicGoalSharePath("alice@example", "goal-123")).toBe("/u/alice%40example/goals/goal-123");
});
it("encodes goalId with special characters", () => {
expect(buildPublicGoalSharePath("alice", "goal/123")).toBe("/u/alice/goals/goal%2F123");
expect(buildPublicGoalSharePath("alice", "goal#123")).toBe("/u/alice/goals/goal%23123");
});
});
describe("buildPublicGoalShareUrl", () => {
it("builds correct URL", () => { expect(buildPublicGoalShareUrl("https://app.devtrack.io", "alice", "goal-123")).toBe("https://app.devtrack.io/u/alice/goals/goal-123"); });
it("encodes special characters in path", () => { expect(buildPublicGoalShareUrl("https://app.devtrack.io", "alice bob", "goal-123")).toBe("https://app.devtrack.io/u/alice%20bob/goals/goal-123"); });
});
60 changes: 60 additions & 0 deletions test/upstash-rest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { getUpstashConfig, upstashPipeline, upstashRateLimitFixedWindow, upstashTryAcquireLock } from "../src/lib/upstash-rest";
describe("getUpstashConfig", () => {
beforeEach(() => { delete process.env.UPSTASH_REDIS_REST_URL; delete process.env.UPSTASH_REDIS_REST_TOKEN; });
it("returns null when neither env var is set", () => { expect(getUpstashConfig()).toBeNull(); });
it("returns null when only URL is set", () => { process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; expect(getUpstashConfig()).toBeNull(); });
it("returns null when only token is set", () => { process.env.UPSTASH_REDIS_REST_TOKEN = "token123"; expect(getUpstashConfig()).toBeNull(); });
it("returns config when both env vars are set", () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io";
process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
expect(getUpstashConfig()).toEqual({ url: "https://test.upstash.io", token: "token123" });
});
});
describe("upstashPipeline", () => {
beforeEach(() => { delete process.env.UPSTASH_REDIS_REST_URL; delete process.env.UPSTASH_REDIS_REST_TOKEN; vi.restoreAllMocks(); });
it("returns empty array when not configured", async () => { expect(await upstashPipeline([["GET","key"]])).toEqual([]); });
it("returns empty array when fetch fails", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 500 })) as unknown as typeof fetch;
expect(await upstashPipeline([["GET","key"]])).toEqual([]);
});
it("returns parsed JSON on success", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{result:"OK"}]), {status:200,headers:{"content-type":"application/json"}})) as unknown as typeof fetch;
expect(await upstashPipeline([["SET","key","value"]])).toEqual([{result:"OK"}]);
});
});
describe("upstashRateLimitFixedWindow", () => {
beforeEach(() => { delete process.env.UPSTASH_REDIS_REST_URL; delete process.env.UPSTASH_REDIS_REST_TOKEN; vi.restoreAllMocks(); });
it("returns allowed when not configured", async () => { expect(await upstashRateLimitFixedWindow({key:"test",limit:5,windowSeconds:60})).toEqual({allowed:true}); });
it("returns allowed when upstash fetch fails", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(null,{status:500})) as unknown as typeof fetch;
expect(await upstashRateLimitFixedWindow({key:"test",limit:5,windowSeconds:60})).toEqual({allowed:true});
});
it("returns allowed when count is NaN", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{result:NaN},{result:60}]),{status:200,headers:{"content-type":"application/json"}})) as unknown as typeof fetch;
expect(await upstashRateLimitFixedWindow({key:"test",limit:5,windowSeconds:60})).toEqual({allowed:true});
});
it("returns not allowed when count exceeds limit", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{result:10},{result:45}]),{status:200,headers:{"content-type":"application/json"}})) as unknown as typeof fetch;
expect(await upstashRateLimitFixedWindow({key:"test",limit:5,windowSeconds:60})).toEqual({allowed:false,retryAfter:45});
});
});
describe("upstashTryAcquireLock", () => {
beforeEach(() => { delete process.env.UPSTASH_REDIS_REST_URL; delete process.env.UPSTASH_REDIS_REST_TOKEN; vi.restoreAllMocks(); });
it("returns false when not configured", async () => { expect(await upstashTryAcquireLock({key:"lock:test",ttlSeconds:10})).toBe(false); });
it("returns true when SET returns OK", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{result:"OK"}]),{status:200,headers:{"content-type":"application/json"}})) as unknown as typeof fetch;
expect(await upstashTryAcquireLock({key:"lock:test",ttlSeconds:10})).toBe(true);
});
it("returns false when SET returns null", async () => {
process.env.UPSTASH_REDIS_REST_URL = "https://test.upstash.io"; process.env.UPSTASH_REDIS_REST_TOKEN = "token123";
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{result:null}]),{status:200,headers:{"content-type":"application/json"}})) as unknown as typeof fetch;
expect(await upstashTryAcquireLock({key:"lock:test",ttlSeconds:10})).toBe(false);
});
});
Loading