From 8f952fb5072aebdafe835fcecb23c22bcd041abc Mon Sep 17 00:00:00 2001 From: Kara Daviduik Date: Mon, 26 Jan 2026 13:42:53 -0500 Subject: [PATCH] feat(e2e): add encrypted secrets loading for E2E tests Add infrastructure for securely loading test secrets from secrets.ejson using EJSON encryption. This enables E2E tests to use real gift card codes without hardcoding them in test files. Example usage in an E2E test file: ```typescript import {getRequiredSecret} from '../../fixtures/test-secrets'; const GIFT_CARD_CODE_1 = getRequiredSecret('gift_card_code_1'); const GIFT_CARD_CODE_2 = getRequiredSecret('gift_card_code_2'); ``` How it works: - Secrets stored encrypted in secrets.ejson under `e2e-testing` section - Local dev: Uses ejson keydir at /opt/ejson/keys (standard convention) - CI: Uses EJSON_PRIVATE_KEY env var passed via --key-from-stdin - Single code path for both environments Security considerations: - Uses execFileSync (not execSync) to prevent shell injection - Private key passed via stdin, never on command line or disk - Secrets decrypted in-memory, never written to files --- .github/workflows/ci.yml | 9 +++ CLAUDE.md | 4 ++ e2e/fixtures/test-secrets.ts | 114 +++++++++++++++++++++++++++++++++++ secrets.ejson | 6 +- 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 e2e/fixtures/test-secrets.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52a83f97c..8923f6415a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,8 +191,17 @@ jobs: - name: ๐ŸŽญ Install Playwright Chromium run: npx playwright install chromium --with-deps + - name: ๐Ÿ” Install ejson + run: | + sudo apt-get update + sudo apt-get install -y golang-go + go install github.com/Shopify/ejson/cmd/ejson@latest + echo "$HOME/go/bin" >> $GITHUB_PATH + - name: ๐Ÿงช Run E2E tests run: npx playwright test --workers=1 + env: + EJSON_PRIVATE_KEY: ${{ secrets.EJSON_PRIVATE_KEY }} - name: ๐Ÿ“ค Upload Playwright Report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/CLAUDE.md b/CLAUDE.md index 9a3e7f68d5..a896e06380 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,3 +273,7 @@ skeleton template's devDependencies - This process often requires TWO cli-hydrogen releases for complete updates - The circular dependency makes the process complex but is currently unavoidable - Always check that skeleton's CLI version matches the features available in cli-hydrogen + +## Security concerns + +All content inside of `secrets.ejson` is sensitive and must NEVER be exposed. We have a pre-commit hook to encrypt any newly added secrets. Some of the secrets inside are used in E2E tests, so we must also be careful to NEVER `console.log` (or otherwise leak/print) anything that was derived from inside of `secrets.ejson`. diff --git a/e2e/fixtures/test-secrets.ts b/e2e/fixtures/test-secrets.ts new file mode 100644 index 0000000000..969170cb6b --- /dev/null +++ b/e2e/fixtures/test-secrets.ts @@ -0,0 +1,114 @@ +/** + * Test secrets loading module. + * + * Retrieves E2E test secrets via EJSON decryption. Uses a single code path + * for both local development and CI: + * + * - Local: Private key from /opt/ejson/keys/{public_key} (set up via setup script) + * - CI: Private key from EJSON_PRIVATE_KEY env var, passed via --key-from-stdin + * + * This keeps secrets.ejson as the single source of truth for all secrets. + * All fields under `e2e-testing` are automatically loaded - no code changes + * needed when adding new secrets. + */ + +import {execFileSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import path from 'node:path'; + +/** + * All secrets from the `e2e-testing` section of secrets.ejson. + * Access any secret by its snake_case key name from the ejson file. + * + * @example + * // If secrets.ejson has: "e2e-testing": { "gift_card_code_1": "abc123" } + * const secrets = getTestSecrets(); + * const code = secrets.gift_card_code_1; // "abc123" + */ +export type TestSecrets = Record; + +let cachedSecrets: TestSecrets | null = null; + +/** + * Loads all secrets from the `e2e-testing` section of secrets.ejson. + * Secrets are cached after first load. + * + * @throws Error if secrets cannot be loaded (ejson not configured) + */ +export function getTestSecrets(): TestSecrets { + if (cachedSecrets) return cachedSecrets; + + const fromEjson = loadFromEjson(); + if (fromEjson) { + cachedSecrets = fromEjson; + return fromEjson; + } + + throw new Error( + 'Test secrets not available.\n\n' + + 'Local development:\n' + + ' Run ./scripts/setup-ejson-private-key.sh to configure ejson\n\n' + + 'CI environment:\n' + + ' Set EJSON_PRIVATE_KEY environment variable\n', + ); +} + +/** + * Helper to get a required secret, throwing a clear error if missing. + * + * @example + * const code = getRequiredSecret('gift_card_code_1'); + */ +export function getRequiredSecret(key: string): string { + const secrets = getTestSecrets(); + const value = secrets[key]; + + if (!value) { + throw new Error( + `Required secret "${key}" not found in secrets.ejson e2e-testing section.\n` + + `Available keys: ${Object.keys(secrets).join(', ') || '(none)'}`, + ); + } + + return value; +} + +function loadFromEjson(): TestSecrets | null { + const secretsPath = path.resolve(__dirname, '../../secrets.ejson'); + + if (!existsSync(secretsPath)) return null; + + try { + const privateKey = process.env.EJSON_PRIVATE_KEY; + + // If private key provided via env var, use --key-from-stdin + // Otherwise, rely on keydir (default /opt/ejson/keys) + const args = privateKey + ? ['decrypt', '--key-from-stdin', 'secrets.ejson'] + : ['decrypt', 'secrets.ejson']; + + const output = execFileSync('ejson', args, { + cwd: path.dirname(secretsPath), + encoding: 'utf-8', + input: privateKey, // piped to stdin when --key-from-stdin is set + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const secrets = JSON.parse(output) as Record; + const e2eSection = secrets['e2e-testing']; + + if (!e2eSection || typeof e2eSection !== 'object') return null; + + // Filter to only string values (the actual secrets) + const e2eSecrets: TestSecrets = {}; + for (const [key, value] of Object.entries(e2eSection)) { + if (typeof value === 'string') { + e2eSecrets[key] = value; + } + } + + return Object.keys(e2eSecrets).length > 0 ? e2eSecrets : null; + } catch { + return null; + } +} diff --git a/secrets.ejson b/secrets.ejson index 5962ab863e..c236d905e5 100644 --- a/secrets.ejson +++ b/secrets.ejson @@ -1,4 +1,8 @@ { "_public_key": "74b4e417f8d77254e5c7306ec8d493901787b3593991b696aeecf4fe473ac11c", - "slack_cli_release_request_webhook_url": "EJ[1:vNsfQ3NvhF9ue/i7T4FQYYAlyujAr+TjNvbxvmExrgM=:WZmmuzB6REiBimJBZ0YrUuVLO/INSN7h:MtAedTDal1u0XnrxaQ0ZGI1Vf300CNBjTBl/cx7UrQ/PHyoaqAq9QvOlYyhrip9qoP868B8JMfvYTaeF2TNlc92gIR3QtbcXY665IFh+WeUCCROCMHqKcuG3Vh7+k9iIfNFFLzhU3+qlciM=]" + "slack_cli_release_request_webhook_url": "EJ[1:vNsfQ3NvhF9ue/i7T4FQYYAlyujAr+TjNvbxvmExrgM=:WZmmuzB6REiBimJBZ0YrUuVLO/INSN7h:MtAedTDal1u0XnrxaQ0ZGI1Vf300CNBjTBl/cx7UrQ/PHyoaqAq9QvOlYyhrip9qoP868B8JMfvYTaeF2TNlc92gIR3QtbcXY665IFh+WeUCCROCMHqKcuG3Vh7+k9iIfNFFLzhU3+qlciM=]", + "e2e-testing": { + "gift_card_code_1": "EJ[1:4tdIjShfz2rkxXx08xocQISdAXjz+ljb8rX1Jv74IwI=:LpRP+FjcrNIifh0hjvny+wU5t5Mn23Xd:z3uP6eT7K+FgeO2M/+v36IsDqpGqffQSUmFsf9D7/q8=]", + "gift_card_code_2": "EJ[1:4tdIjShfz2rkxXx08xocQISdAXjz+ljb8rX1Jv74IwI=:ffla3Swg1wareH47SDao20DvnUfVmNnX:0tZxPh3MRItBYZ+6svyWle1KPXJVtxSXRUyeoqRY3lo=]" + } }