CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
server-targets-crypto.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 783dd46 | 1 | /** |
| 2 | * Pure-function tests for src/lib/server-targets-crypto.ts. | |
| 3 | * | |
| 4 | * Mirror of the workflow-secrets-crypto suite: round-trip, IV randomness, | |
| 5 | * tamper detection, env-name validation, dotenv rendering. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, it, expect, afterEach } from "bun:test"; | |
| 9 | import { | |
| 10 | encryptValue, | |
| 11 | decryptValue, | |
| 12 | getMasterKey, | |
| 13 | isValidEnvName, | |
| 14 | renderDotenv, | |
| 15 | } from "../lib/server-targets-crypto"; | |
| 16 | ||
| 17 | const TEST_KEY = | |
| 18 | "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| 19 | const original = process.env.SERVER_TARGETS_KEY; | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | if (original === undefined) delete process.env.SERVER_TARGETS_KEY; | |
| 23 | else process.env.SERVER_TARGETS_KEY = original; | |
| 24 | }); | |
| 25 | ||
| 26 | describe("getMasterKey", () => { | |
| 27 | it("returns null when env is unset", () => { | |
| 28 | delete process.env.SERVER_TARGETS_KEY; | |
| 29 | expect(getMasterKey()).toBeNull(); | |
| 30 | }); | |
| 31 | ||
| 32 | it("returns null when env is not 32 bytes", () => { | |
| 33 | process.env.SERVER_TARGETS_KEY = "abcd"; | |
| 34 | expect(getMasterKey()).toBeNull(); | |
| 35 | }); | |
| 36 | ||
| 37 | it("returns 32-byte buffer when env is valid hex", () => { | |
| 38 | process.env.SERVER_TARGETS_KEY = TEST_KEY; | |
| 39 | const key = getMasterKey(); | |
| 40 | expect(key).not.toBeNull(); | |
| 41 | expect(key!.length).toBe(32); | |
| 42 | }); | |
| 43 | ||
| 44 | it("ignores non-hex input", () => { | |
| 45 | process.env.SERVER_TARGETS_KEY = "zzzz".repeat(16); | |
| 46 | expect(getMasterKey()).toBeNull(); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("encrypt/decrypt round-trip", () => { | |
| 51 | it("recovers the original plaintext", () => { | |
| 52 | process.env.SERVER_TARGETS_KEY = TEST_KEY; | |
| 53 | const enc = encryptValue("hello-server-target"); | |
| 54 | expect(enc.ok).toBe(true); | |
| 55 | if (!enc.ok) return; | |
| 56 | const dec = decryptValue(enc.ciphertext); | |
| 57 | expect(dec.ok).toBe(true); | |
| 58 | if (!dec.ok) return; | |
| 59 | expect(dec.plaintext).toBe("hello-server-target"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("uses a fresh IV per encryption so identical plaintexts diverge", () => { | |
| 63 | process.env.SERVER_TARGETS_KEY = TEST_KEY; | |
| 64 | const a = encryptValue("same"); | |
| 65 | const b = encryptValue("same"); | |
| 66 | expect(a.ok && b.ok).toBe(true); | |
| 67 | if (!a.ok || !b.ok) return; | |
| 68 | expect(a.ciphertext).not.toBe(b.ciphertext); | |
| 69 | }); | |
| 70 | ||
| 71 | it("rejects encrypt when key missing", () => { | |
| 72 | delete process.env.SERVER_TARGETS_KEY; | |
| 73 | const enc = encryptValue("anything"); | |
| 74 | expect(enc.ok).toBe(false); | |
| 75 | }); | |
| 76 | ||
| 77 | it("detects tampered ciphertext via GCM auth tag", () => { | |
| 78 | process.env.SERVER_TARGETS_KEY = TEST_KEY; | |
| 79 | const enc = encryptValue("payload"); | |
| 80 | if (!enc.ok) throw new Error("setup failed"); | |
| 81 | // Flip a byte inside the ciphertext portion (after IV+tag) — base64 | |
| 82 | // decode → mutate → re-encode. | |
| 83 | const buf = Buffer.from(enc.ciphertext, "base64"); | |
| 84 | buf[buf.length - 1] ^= 0xff; | |
| 85 | const tampered = buf.toString("base64"); | |
| 86 | const dec = decryptValue(tampered); | |
| 87 | expect(dec.ok).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | it("rejects too-short blob", () => { | |
| 91 | process.env.SERVER_TARGETS_KEY = TEST_KEY; | |
| 92 | const dec = decryptValue(Buffer.from("short").toString("base64")); | |
| 93 | expect(dec.ok).toBe(false); | |
| 94 | }); | |
| 95 | }); | |
| 96 | ||
| 97 | describe("isValidEnvName", () => { | |
| 98 | it("accepts conventional env names", () => { | |
| 99 | expect(isValidEnvName("FOO")).toBe(true); | |
| 100 | expect(isValidEnvName("FOO_BAR")).toBe(true); | |
| 101 | expect(isValidEnvName("_PRIVATE")).toBe(true); | |
| 102 | expect(isValidEnvName("A1_B2")).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | it("rejects lowercase, dashes, leading digits, empty", () => { | |
| 106 | expect(isValidEnvName("foo")).toBe(false); | |
| 107 | expect(isValidEnvName("FOO-BAR")).toBe(false); | |
| 108 | expect(isValidEnvName("1FOO")).toBe(false); | |
| 109 | expect(isValidEnvName("")).toBe(false); | |
| 110 | expect(isValidEnvName(undefined)).toBe(false); | |
| 111 | expect(isValidEnvName(null)).toBe(false); | |
| 112 | }); | |
| 113 | }); | |
| 114 | ||
| 115 | describe("renderDotenv", () => { | |
| 116 | it("renders alphabetised KEY='value' lines", () => { | |
| 117 | const out = renderDotenv({ BAR: "two", FOO: "one" }); | |
| 118 | expect(out).toBe("BAR='two'\nFOO='one'\n"); | |
| 119 | }); | |
| 120 | ||
| 121 | it("escapes embedded single quotes", () => { | |
| 122 | const out = renderDotenv({ FOO: "a'b" }); | |
| 123 | // a'b → 'a'\''b' — POSIX single-quote escape. | |
| 124 | expect(out).toBe("FOO='a'\\''b'\n"); | |
| 125 | }); | |
| 126 | ||
| 127 | it("returns empty string for empty map", () => { | |
| 128 | expect(renderDotenv({})).toBe(""); | |
| 129 | }); | |
| 130 | ||
| 131 | it("never leaves a value unquoted", () => { | |
| 132 | const out = renderDotenv({ KEY: "has spaces and $vars" }); | |
| 133 | expect(out).toBe("KEY='has spaces and $vars'\n"); | |
| 134 | }); | |
| 135 | }); |