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=]" + } }