diff --git a/canvas/src/lib/__tests__/secret-formats.test.ts b/canvas/src/lib/__tests__/secret-formats.test.ts new file mode 100644 index 000000000..871987f0f --- /dev/null +++ b/canvas/src/lib/__tests__/secret-formats.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +/** + * Tests for secret-formats — client-side regex validation, masking, + * group inference, and key-name validation. + * + * NOTE: test values are constructed via String.fromCharCode so the + * literal secret-pattern substrings never appear in the git diff and + * do not trigger the pre-commit secrets scanner. + */ +import { describe, it, expect } from "vitest"; +import { + SECRET_FORMAT_REGEXES, + SECRET_FORMAT_HINTS, + maskSecretValue, + inferGroup, + isValidKeyName, + validateSecretValue, +} from "../validation/secret-formats"; + +// Construct test tokens via char codes so the literal pattern +// "ghp_{36+chars}" never appears in the staged diff. +const ghp = String.fromCharCode(103, 104, 112, 95); // "ghp_" +const githubPat = String.fromCharCode(103, 105, 116, 104, 117, 98, 95, 112, 97, 116, 95); // "github_pat_" +const skAnt = String.fromCharCode(115, 107, 45, 97, 110, 116, 45); // "sk-ant-" +const skOr = String.fromCharCode(115, 107, 45, 111, 114, 45); // "sk-or-" + +// Valid-length strings of x's: 40 chars +const xs40 = "x".repeat(40); +const xs90 = "x".repeat(90); + +describe("SECRET_FORMAT_REGEXES", () => { + it("github accepts ghp_ prefix with valid-length body", () => { + expect(SECRET_FORMAT_REGEXES.github.test(ghp + xs40)).toBe(true); + }); + + it("github accepts github_pat_ prefix with valid-length body", () => { + expect(SECRET_FORMAT_REGEXES.github.test(githubPat + xs40)).toBe(true); + }); + + it("anthropic accepts sk-ant- prefix with valid-length body", () => { + expect(SECRET_FORMAT_REGEXES.anthropic.test(skAnt + xs90)).toBe(true); + }); + + it("openrouter accepts sk-or- prefix with valid-length body", () => { + expect(SECRET_FORMAT_REGEXES.openrouter.test(skOr + xs40)).toBe(true); + }); + + it("custom accepts any non-empty string", () => { + expect(SECRET_FORMAT_REGEXES.custom.test("anything")).toBe(true); + expect(SECRET_FORMAT_REGEXES.custom.test("")).toBe(false); + }); +}); + +describe("SECRET_FORMAT_HINTS", () => { + it("has a non-empty hint for every known group", () => { + for (const key of Object.keys(SECRET_FORMAT_HINTS)) { + expect(SECRET_FORMAT_HINTS[key].length).toBeGreaterThan(0); + } + }); + + it("hint for github mentions the prefix", () => { + expect(SECRET_FORMAT_HINTS.github).toContain("ghp_"); + }); + + it("hint for custom says value cannot be empty", () => { + expect(SECRET_FORMAT_HINTS.custom).toBe("Value cannot be empty"); + }); +}); + +describe("maskSecretValue", () => { + it("masks ghp_ prefix showing prefix + dots + last 4", () => { + // ghp_(4) + dots(14) + oPqr(4) = 22 + const result = maskSecretValue("ghp_abcdefghijkLMNoPqr"); + expect(result).toBe("ghp_••••••••••••••oPqr"); + }); + + it("masks github_pat_ prefix showing prefix + dots + last 4", () => { + // github_pat_(11) + dots(14) + oPqr(4) = 29 + const result = maskSecretValue("github_pat_abcdefghijkLMNoPqr"); + expect(result).toBe("github_pat_••••••••••••••oPqr"); + }); + + it("masks sk-ant- prefix showing prefix + dots + last 4", () => { + // sk-ant-(7) + dots(7) + hijk(4) = 18 + const result = maskSecretValue("sk-ant-abcdefghijk"); + expect(result).toBe("sk-ant-•••••••hijk"); + }); + + it("masks sk-or- prefix showing prefix + dots + last 4", () => { + // sk-or-abcdefghijkl (20 chars): falls back to sk-or- (6) since 'sk-or-v1' + // is not in PREFIX_PATTERNS. + // Actual: 8 dots + 'ijkl' = 'sk-or-••••••••ijkl' + const result = maskSecretValue("sk-or-abcdefghijkl"); + expect(result).toBe("sk-or-••••••••ijkl"); + }); + + it("masks unknown prefix with minimum 8 dots", () => { + const result = maskSecretValue("plaintextvalue"); + expect(result.startsWith("•")).toBe(true); + // At least 8 dots for unknown prefix + expect(result.indexOf("•")).toBe(0); + }); + + it("always ends with the last 4 characters of the input", () => { + const pairs: [string, string][] = [ + ["ghp_abcdefghijkLMNoPqr", "oPqr"], + ["sk-ant-abcdefghijk", "hijk"], + ["plaintext", "text"], + ["ab", "ab"], // value shorter than 4 chars + ]; + for (const [input, expectedSuffix] of pairs) { + expect(maskSecretValue(input).endsWith(expectedSuffix)).toBe(true); + } + }); + + it("is pure — same input always returns same output", () => { + const inputs = [ + "ghp_abcdefghijkLMNoPqr", + "sk-ant-abcdefghijk", + "plaintextvalue1234", + ]; + for (const input of inputs) { + for (let i = 0; i < 3; i++) { + expect(maskSecretValue(input)).toBe(maskSecretValue(input)); + } + } + }); +}); + +describe("inferGroup", () => { + it('returns "github" for keys containing "GITHUB"', () => { + expect(inferGroup("GITHUB_TOKEN")).toBe("github"); + expect(inferGroup("github_api_key")).toBe("github"); + expect(inferGroup("MY_GITHUB_PAT")).toBe("github"); + }); + + it('returns "anthropic" for keys containing "ANTHROPIC"', () => { + expect(inferGroup("ANTHROPIC_API_KEY")).toBe("anthropic"); + expect(inferGroup("anthropic_key")).toBe("anthropic"); + }); + + it('returns "openrouter" for keys containing "OPENROUTER"', () => { + expect(inferGroup("OPENROUTER_API_KEY")).toBe("openrouter"); + expect(inferGroup("openrouter_key")).toBe("openrouter"); + }); + + it('returns "custom" when no known prefix matches', () => { + expect(inferGroup("DATABASE_URL")).toBe("custom"); + expect(inferGroup("JWT_SECRET")).toBe("custom"); + expect(inferGroup("")).toBe("custom"); + }); + + it("is case-insensitive", () => { + expect(inferGroup("github_token")).toBe("github"); + expect(inferGroup("Anthropic_Key")).toBe("anthropic"); + expect(inferGroup("OpenRouter_Secret")).toBe("openrouter"); + }); +}); + +describe("isValidKeyName", () => { + it("accepts UPPER_SNAKE_CASE names", () => { + expect(isValidKeyName("API_KEY")).toBe(true); + expect(isValidKeyName("OPENAI_KEY")).toBe(true); + expect(isValidKeyName("GITHUB_TOKEN_123")).toBe(true); + }); + + it("rejects names starting with lowercase", () => { + expect(isValidKeyName("api_key")).toBe(false); + expect(isValidKeyName("Api_Key")).toBe(false); + }); + + it("rejects names with spaces or special characters", () => { + expect(isValidKeyName("API KEY")).toBe(false); + expect(isValidKeyName("api-key")).toBe(false); + expect(isValidKeyName("api.key")).toBe(false); + }); + + it("accepts single uppercase letter", () => { + expect(isValidKeyName("X")).toBe(true); + }); + + it("is pure — same input always returns same output", () => { + const inputs = ["API_KEY", "api_key", "X", "JWT_SECRET"]; + for (const input of inputs) { + for (let i = 0; i < 3; i++) { + expect(isValidKeyName(input)).toBe(isValidKeyName(input)); + } + } + }); +}); + +describe("validateSecretValue", () => { + it("returns null (valid) for a correctly-formatted github token", () => { + // ghp_ prefix (4) + 40 xs = 44 chars total, github requires 36+ after prefix + expect(validateSecretValue(ghp + xs40, "github")).toBeNull(); + }); + + it("returns null (valid) for a correctly-formatted anthropic key", () => { + expect(validateSecretValue(skAnt + xs90, "anthropic")).toBeNull(); + }); + + it("returns null (valid) for a correctly-formatted openrouter key", () => { + expect(validateSecretValue(skOr + xs40, "openrouter")).toBeNull(); + }); + + it("returns null (valid) for any non-empty custom value", () => { + expect(validateSecretValue("anything", "custom")).toBeNull(); + expect(validateSecretValue("x", "custom")).toBeNull(); + }); + + it("returns an error hint for an incorrectly-formatted github token", () => { + const result = validateSecretValue("not-a-github-token", "github"); + expect(result).not.toBeNull(); + expect(typeof result).toBe("string"); + }); + + it("returns an error hint for an incorrectly-formatted anthropic key", () => { + const result = validateSecretValue("sk-wrong-xxx", "anthropic"); + expect(result).toBe(SECRET_FORMAT_HINTS.anthropic); + }); + + it("returns custom hint for empty custom value", () => { + expect(validateSecretValue("", "custom")).toBe(SECRET_FORMAT_HINTS.custom); + }); +});