CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-secrets-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.
| abfa9ad | 1 | /** |
| 2 | * Unit tests for src/lib/workflow-secrets-crypto.ts (Agent 2, Sprint 1). | |
| 3 | * | |
| 4 | * Pure-function coverage: crypto roundtrip, IV randomisation, tamper | |
| 5 | * detection, and `${{ secrets.X }}` template substitution rules. | |
| 6 | * | |
| 7 | * Env management: each test may mutate WORKFLOW_SECRETS_KEY, so we snapshot | |
| 8 | * the original value once and restore it in afterEach. The module reads | |
| 9 | * `process.env.WORKFLOW_SECRETS_KEY` at call time (see `getMasterKey`), so | |
| 10 | * no module-cache juggling is required. | |
| 11 | */ | |
| 12 | ||
| 13 | import { describe, it, expect, afterEach, afterAll } from "bun:test"; | |
| 14 | import { | |
| 15 | encryptSecret, | |
| 16 | decryptSecret, | |
| 17 | substituteSecrets, | |
| 18 | getMasterKey, | |
| 19 | } from "../lib/workflow-secrets-crypto"; | |
| 20 | ||
| 21 | const TEST_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| 22 | const originalKey = process.env.WORKFLOW_SECRETS_KEY; | |
| 23 | ||
| 24 | function restoreKey() { | |
| 25 | if (originalKey === undefined) delete process.env.WORKFLOW_SECRETS_KEY; | |
| 26 | else process.env.WORKFLOW_SECRETS_KEY = originalKey; | |
| 27 | } | |
| 28 | ||
| 29 | afterEach(() => { | |
| 30 | restoreKey(); | |
| 31 | }); | |
| 32 | ||
| 33 | afterAll(() => { | |
| 34 | restoreKey(); | |
| 35 | }); | |
| 36 | ||
| 37 | describe("workflow-secrets-crypto — encryptSecret / decryptSecret", () => { | |
| 38 | it("returns ok:false when WORKFLOW_SECRETS_KEY is unset", () => { | |
| 39 | delete process.env.WORKFLOW_SECRETS_KEY; | |
| 40 | expect(getMasterKey()).toBeNull(); | |
| 41 | const r = encryptSecret("hello"); | |
| 42 | expect(r.ok).toBe(false); | |
| 43 | if (!r.ok) expect(r.error).toMatch(/WORKFLOW_SECRETS_KEY/); | |
| 44 | }); | |
| 45 | ||
| 46 | it("encrypt -> decrypt roundtrip yields the original plaintext", () => { | |
| 47 | process.env.WORKFLOW_SECRETS_KEY = TEST_KEY; | |
| 48 | const enc = encryptSecret("s3cret-value with spaces & symbols !@#$%"); | |
| 49 | expect(enc.ok).toBe(true); | |
| 50 | if (!enc.ok) return; | |
| 51 | expect(typeof enc.ciphertext).toBe("string"); | |
| 52 | expect(enc.ciphertext.length).toBeGreaterThan(0); | |
| 53 | ||
| 54 | const dec = decryptSecret(enc.ciphertext); | |
| 55 | expect(dec.ok).toBe(true); | |
| 56 | if (!dec.ok) return; | |
| 57 | expect(dec.plaintext).toBe("s3cret-value with spaces & symbols !@#$%"); | |
| 58 | }); | |
| 59 | ||
| 60 | it("produces different ciphertexts on repeat encryption (IV randomisation)", () => { | |
| 61 | process.env.WORKFLOW_SECRETS_KEY = TEST_KEY; | |
| 62 | const a = encryptSecret("same-input"); | |
| 63 | const b = encryptSecret("same-input"); | |
| 64 | expect(a.ok).toBe(true); | |
| 65 | expect(b.ok).toBe(true); | |
| 66 | if (!a.ok || !b.ok) return; | |
| 67 | expect(a.ciphertext).not.toBe(b.ciphertext); | |
| 68 | // Both must still round-trip to the same plaintext. | |
| 69 | const da = decryptSecret(a.ciphertext); | |
| 70 | const db = decryptSecret(b.ciphertext); | |
| 71 | expect(da.ok && db.ok).toBe(true); | |
| 72 | if (da.ok) expect(da.plaintext).toBe("same-input"); | |
| 73 | if (db.ok) expect(db.plaintext).toBe("same-input"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("decryptSecret rejects a tampered ciphertext with ok:false", () => { | |
| 77 | process.env.WORKFLOW_SECRETS_KEY = TEST_KEY; | |
| 78 | const enc = encryptSecret("tamper-me"); | |
| 79 | expect(enc.ok).toBe(true); | |
| 80 | if (!enc.ok) return; | |
| 81 | // Flip the last character (which lives in the ciphertext body) to | |
| 82 | // invalidate the GCM auth tag / ciphertext pair. | |
| 83 | const ct = enc.ciphertext; | |
| 84 | const flipped = ct.slice(0, -2) + (ct.slice(-2) === "AA" ? "BB" : "AA"); | |
| 85 | const dec = decryptSecret(flipped); | |
| 86 | expect(dec.ok).toBe(false); | |
| 87 | }); | |
| 88 | ||
| 89 | it("decryptSecret rejects a truncated blob", () => { | |
| 90 | process.env.WORKFLOW_SECRETS_KEY = TEST_KEY; | |
| 91 | const enc = encryptSecret("xyz"); | |
| 92 | expect(enc.ok).toBe(true); | |
| 93 | if (!enc.ok) return; | |
| 94 | // Cut it down to only the first few base64 bytes — well below IV+tag. | |
| 95 | const truncated = enc.ciphertext.slice(0, 4); | |
| 96 | const dec = decryptSecret(truncated); | |
| 97 | expect(dec.ok).toBe(false); | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("workflow-secrets-crypto — substituteSecrets", () => { | |
| 102 | it("replaces a simple ${{ secrets.FOO }} token with the value", () => { | |
| 103 | const out = substituteSecrets("curl -H auth:${{ secrets.TOKEN }}", { | |
| 104 | TOKEN: "abc", | |
| 105 | }); | |
| 106 | expect(out).toBe("curl -H auth:abc"); | |
| 107 | }); | |
| 108 | ||
| 109 | it("tolerates flexible whitespace inside the token", () => { | |
| 110 | const out1 = substituteSecrets("${{secrets.FOO}}", { FOO: "1" }); | |
| 111 | const out2 = substituteSecrets("${{ secrets.FOO }}", { FOO: "1" }); | |
| 112 | const out3 = substituteSecrets("${{\tsecrets.FOO\t}}", { FOO: "1" }); | |
| 113 | expect(out1).toBe("1"); | |
| 114 | expect(out2).toBe("1"); | |
| 115 | expect(out3).toBe("1"); | |
| 116 | }); | |
| 117 | ||
| 118 | it("leaves unknown secret names untouched", () => { | |
| 119 | const out = substituteSecrets( | |
| 120 | "have=${{ secrets.KNOWN }} miss=${{ secrets.UNKNOWN }}", | |
| 121 | { KNOWN: "yes" }, | |
| 122 | ); | |
| 123 | expect(out).toBe("have=yes miss=${{ secrets.UNKNOWN }}"); | |
| 124 | }); | |
| 125 | ||
| 126 | it("honours $${{ secrets.X }} as a literal escape", () => { | |
| 127 | const out = substituteSecrets("$${{ secrets.FOO }}", { FOO: "value" }); | |
| 128 | expect(out).toBe("${{ secrets.FOO }}"); | |
| 129 | // And mixing: one literal + one substituted. | |
| 130 | const mixed = substituteSecrets( | |
| 131 | "lit=$${{ secrets.A }} sub=${{ secrets.A }}", | |
| 132 | { A: "X" }, | |
| 133 | ); | |
| 134 | expect(mixed).toBe("lit=${{ secrets.A }} sub=X"); | |
| 135 | }); | |
| 136 | }); |