Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

workflow-secrets-crypto.test.tsBlame136 lines · 1 contributor
abfa9adClaude1/**
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
13import { describe, it, expect, afterEach, afterAll } from "bun:test";
14import {
15 encryptSecret,
16 decryptSecret,
17 substituteSecrets,
18 getMasterKey,
19} from "../lib/workflow-secrets-crypto";
20
21const TEST_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22const originalKey = process.env.WORKFLOW_SECRETS_KEY;
23
24function restoreKey() {
25 if (originalKey === undefined) delete process.env.WORKFLOW_SECRETS_KEY;
26 else process.env.WORKFLOW_SECRETS_KEY = originalKey;
27}
28
29afterEach(() => {
30 restoreKey();
31});
32
33afterAll(() => {
34 restoreKey();
35});
36
37describe("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
101describe("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});