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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
workflow-secrets-crypto.test.ts4.8 KB · 136 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
 * Unit tests for src/lib/workflow-secrets-crypto.ts (Agent 2, Sprint 1).
 *
 * Pure-function coverage: crypto roundtrip, IV randomisation, tamper
 * detection, and `${{ secrets.X }}` template substitution rules.
 *
 * Env management: each test may mutate WORKFLOW_SECRETS_KEY, so we snapshot
 * the original value once and restore it in afterEach. The module reads
 * `process.env.WORKFLOW_SECRETS_KEY` at call time (see `getMasterKey`), so
 * no module-cache juggling is required.
 */

import { describe, it, expect, afterEach, afterAll } from "bun:test";
import {
  encryptSecret,
  decryptSecret,
  substituteSecrets,
  getMasterKey,
} from "../lib/workflow-secrets-crypto";

const TEST_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const originalKey = process.env.WORKFLOW_SECRETS_KEY;

function restoreKey() {
  if (originalKey === undefined) delete process.env.WORKFLOW_SECRETS_KEY;
  else process.env.WORKFLOW_SECRETS_KEY = originalKey;
}

afterEach(() => {
  restoreKey();
});

afterAll(() => {
  restoreKey();
});

describe("workflow-secrets-crypto — encryptSecret / decryptSecret", () => {
  it("returns ok:false when WORKFLOW_SECRETS_KEY is unset", () => {
    delete process.env.WORKFLOW_SECRETS_KEY;
    expect(getMasterKey()).toBeNull();
    const r = encryptSecret("hello");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error).toMatch(/WORKFLOW_SECRETS_KEY/);
  });

  it("encrypt -> decrypt roundtrip yields the original plaintext", () => {
    process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
    const enc = encryptSecret("s3cret-value with spaces & symbols !@#$%");
    expect(enc.ok).toBe(true);
    if (!enc.ok) return;
    expect(typeof enc.ciphertext).toBe("string");
    expect(enc.ciphertext.length).toBeGreaterThan(0);

    const dec = decryptSecret(enc.ciphertext);
    expect(dec.ok).toBe(true);
    if (!dec.ok) return;
    expect(dec.plaintext).toBe("s3cret-value with spaces & symbols !@#$%");
  });

  it("produces different ciphertexts on repeat encryption (IV randomisation)", () => {
    process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
    const a = encryptSecret("same-input");
    const b = encryptSecret("same-input");
    expect(a.ok).toBe(true);
    expect(b.ok).toBe(true);
    if (!a.ok || !b.ok) return;
    expect(a.ciphertext).not.toBe(b.ciphertext);
    // Both must still round-trip to the same plaintext.
    const da = decryptSecret(a.ciphertext);
    const db = decryptSecret(b.ciphertext);
    expect(da.ok && db.ok).toBe(true);
    if (da.ok) expect(da.plaintext).toBe("same-input");
    if (db.ok) expect(db.plaintext).toBe("same-input");
  });

  it("decryptSecret rejects a tampered ciphertext with ok:false", () => {
    process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
    const enc = encryptSecret("tamper-me");
    expect(enc.ok).toBe(true);
    if (!enc.ok) return;
    // Flip the last character (which lives in the ciphertext body) to
    // invalidate the GCM auth tag / ciphertext pair.
    const ct = enc.ciphertext;
    const flipped = ct.slice(0, -2) + (ct.slice(-2) === "AA" ? "BB" : "AA");
    const dec = decryptSecret(flipped);
    expect(dec.ok).toBe(false);
  });

  it("decryptSecret rejects a truncated blob", () => {
    process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
    const enc = encryptSecret("xyz");
    expect(enc.ok).toBe(true);
    if (!enc.ok) return;
    // Cut it down to only the first few base64 bytes — well below IV+tag.
    const truncated = enc.ciphertext.slice(0, 4);
    const dec = decryptSecret(truncated);
    expect(dec.ok).toBe(false);
  });
});

describe("workflow-secrets-crypto — substituteSecrets", () => {
  it("replaces a simple ${{ secrets.FOO }} token with the value", () => {
    const out = substituteSecrets("curl -H auth:${{ secrets.TOKEN }}", {
      TOKEN: "abc",
    });
    expect(out).toBe("curl -H auth:abc");
  });

  it("tolerates flexible whitespace inside the token", () => {
    const out1 = substituteSecrets("${{secrets.FOO}}", { FOO: "1" });
    const out2 = substituteSecrets("${{  secrets.FOO  }}", { FOO: "1" });
    const out3 = substituteSecrets("${{\tsecrets.FOO\t}}", { FOO: "1" });
    expect(out1).toBe("1");
    expect(out2).toBe("1");
    expect(out3).toBe("1");
  });

  it("leaves unknown secret names untouched", () => {
    const out = substituteSecrets(
      "have=${{ secrets.KNOWN }} miss=${{ secrets.UNKNOWN }}",
      { KNOWN: "yes" },
    );
    expect(out).toBe("have=yes miss=${{ secrets.UNKNOWN }}");
  });

  it("honours $${{ secrets.X }} as a literal escape", () => {
    const out = substituteSecrets("$${{ secrets.FOO }}", { FOO: "value" });
    expect(out).toBe("${{ secrets.FOO }}");
    // And mixing: one literal + one substituted.
    const mixed = substituteSecrets(
      "lit=$${{ secrets.A }} sub=${{ secrets.A }}",
      { A: "X" },
    );
    expect(mixed).toBe("lit=${{ secrets.A }} sub=X");
  });
});