diff --git a/.changeset/validate-staging-instances.md b/.changeset/validate-staging-instances.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/validate-staging-instances.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index c40b5a102dc..9c1d23ece74 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -37,6 +37,23 @@ concurrency: cancel-in-progress: true jobs: + validate-instances: + name: Validate Staging Instances + runs-on: 'blacksmith-8vcpu-ubuntu-2204' + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }} + sparse-checkout: scripts/validate-staging-instances.mjs + fetch-depth: 1 + + - name: Validate staging instance settings + run: node scripts/validate-staging-instances.mjs + env: + INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} + INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} + integration-tests: name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }}) runs-on: 'blacksmith-8vcpu-ubuntu-2204' diff --git a/scripts/validate-staging-instances.mjs b/scripts/validate-staging-instances.mjs new file mode 100644 index 00000000000..14fcebc0c70 --- /dev/null +++ b/scripts/validate-staging-instances.mjs @@ -0,0 +1,399 @@ +#!/usr/bin/env node + +/** + * Validates that staging Clerk instances have the same settings as their + * production counterparts by comparing FAPI /v1/environment responses. + * + * Usage: + * node scripts/validate-staging-instances.mjs + * + * Reads keys from INTEGRATION_INSTANCE_KEYS / INTEGRATION_STAGING_INSTANCE_KEYS + * env vars, or from integration/.keys.json / integration/.keys.staging.json. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STAGING_KEY_PREFIX = 'clerkstage-'; + +/** + * Paths to ignore during comparison — these are expected to differ between + * production and staging environments. + */ +const IGNORED_PATHS = [ + /\.id$/, + /^auth_config\.id$/, + /\.logo_url$/, + /\.captcha_enabled$/, + /\.captcha_widget_type$/, + /\.enforce_hibp_on_sign_in$/, + /\.disable_hibp$/, +]; + +function isIgnored(path) { + return IGNORED_PATHS.some(pattern => pattern.test(path)); +} + +// ── Key loading ────────────────────────────────────────────────────────────── + +function loadKeys(envVar, filePath) { + let raw; + const errors = []; + + if (process.env[envVar]) { + try { + raw = JSON.parse(process.env[envVar]); + } catch (err) { + return { keys: null, errors: [`Failed to parse ${envVar}: ${err.message}`] }; + } + } else { + try { + raw = JSON.parse(readFileSync(resolve(filePath), 'utf-8')); + } catch { + return { keys: null, errors: [] }; + } + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { keys: null, errors: [`Expected a JSON object from ${envVar || filePath}`] }; + } + + const keys = {}; + for (const [name, entry] of Object.entries(raw)) { + if (entry && typeof entry === 'object' && typeof entry.pk === 'string') { + keys[name] = entry; + } else { + errors.push(`"${name}": missing or invalid pk`); + } + } + + return { keys: Object.keys(keys).length > 0 ? keys : null, errors }; +} + +// ── PK parsing ─────────────────────────────────────────────────────────────── + +function parseFapiDomain(pk) { + const parts = pk.split('_'); + const encoded = parts.slice(2).join('_'); + const decoded = Buffer.from(encoded, 'base64').toString('utf-8'); + return decoded.replace(/\$$/, ''); +} + +// ── Environment fetching ───────────────────────────────────────────────────── + +async function fetchEnvironment(fapiDomain) { + const url = `https://${fapiDomain}/v1/environment`; + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); + } + return res.json(); +} + +// ── Comparison ─────────────────────────────────────────────────────────────── + +const COMPARED_USER_SETTINGS_FIELDS = ['attributes', 'social', 'sign_in', 'sign_up', 'password_settings']; + +/** + * Recursively compare two values and collect paths where they differ. + * For arrays of primitives (like strategy lists), stores structured diff info. + */ +function diffObjects(a, b, path = '') { + const mismatches = []; + + if (a === b) return mismatches; + if (a == null || b == null || typeof a !== typeof b) { + mismatches.push({ path, prod: a, staging: b }); + return mismatches; + } + if (typeof a !== 'object') { + if (a !== b) { + mismatches.push({ path, prod: a, staging: b }); + } + return mismatches; + } + if (Array.isArray(a) && Array.isArray(b)) { + const sortedA = JSON.stringify([...a].sort()); + const sortedB = JSON.stringify([...b].sort()); + if (sortedA !== sortedB) { + // For arrays of primitives, compute added/removed + const flatA = a.flat(Infinity); + const flatB = b.flat(Infinity); + if (flatA.every(v => typeof v !== 'object') && flatB.every(v => typeof v !== 'object')) { + const setA = new Set(flatA); + const setB = new Set(flatB); + const missingOnStaging = [...new Set(flatA.filter(v => !setB.has(v)))]; + const extraOnStaging = [...new Set(flatB.filter(v => !setA.has(v)))]; + mismatches.push({ path, prod: a, staging: b, missingOnStaging, extraOnStaging }); + } else { + mismatches.push({ path, prod: a, staging: b }); + } + } + return mismatches; + } + + const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of allKeys) { + const childPath = path ? `${path}.${key}` : key; + mismatches.push(...diffObjects(a[key], b[key], childPath)); + } + return mismatches; +} + +function compareEnvironments(prodEnv, stagingEnv) { + const mismatches = []; + + // auth_config + mismatches.push(...diffObjects(prodEnv.auth_config, stagingEnv.auth_config, 'auth_config')); + + // organization_settings + const orgFields = ['enabled', 'force_organization_selection']; + for (const field of orgFields) { + mismatches.push( + ...diffObjects( + prodEnv.organization_settings?.[field], + stagingEnv.organization_settings?.[field], + `organization_settings.${field}`, + ), + ); + } + + // user_settings — selected fields only + for (const field of COMPARED_USER_SETTINGS_FIELDS) { + if (field === 'social') { + const prodSocial = prodEnv.user_settings?.social ?? {}; + const stagingSocial = stagingEnv.user_settings?.social ?? {}; + const allProviders = new Set([...Object.keys(prodSocial), ...Object.keys(stagingSocial)]); + for (const provider of allProviders) { + const prodProvider = prodSocial[provider]; + const stagingProvider = stagingSocial[provider]; + if (!prodProvider?.enabled && !stagingProvider?.enabled) continue; + mismatches.push(...diffObjects(prodProvider, stagingProvider, `user_settings.social.${provider}`)); + } + } else { + mismatches.push( + ...diffObjects(prodEnv.user_settings?.[field], stagingEnv.user_settings?.[field], `user_settings.${field}`), + ); + } + } + + return mismatches; +} + +// ── Output formatting ──────────────────────────────────────────────────────── + +/** + * Section display names and the path prefixes they cover. + */ +const SECTIONS = [ + { label: 'Auth Config', prefix: 'auth_config.' }, + { label: 'Organization Settings', prefix: 'organization_settings.' }, + { label: 'Attributes', prefix: 'user_settings.attributes.' }, + { label: 'Social Providers', prefix: 'user_settings.social.' }, + { label: 'Sign In', prefix: 'user_settings.sign_in.' }, + { label: 'Sign Up', prefix: 'user_settings.sign_up.' }, + { label: 'Password Settings', prefix: 'user_settings.password_settings.' }, +]; + +const COL_FIELD = 40; +const COL_VAL = 14; + +function pad(str, len) { + return str.length >= len ? str : str + ' '.repeat(len - str.length); +} + +function formatScalar(val) { + if (val === undefined) return 'undefined'; + if (val === null) return 'null'; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); +} + +/** + * Collapse attribute mismatches: if .enabled differs, skip the child + * fields (first_factors, second_factors, verifications, etc.) since the root + * cause is the enabled flag. + */ +function collapseAttributeMismatches(mismatches) { + const disabledAttrs = new Set(); + for (const m of mismatches) { + if (m.path.startsWith('user_settings.attributes.') && m.path.endsWith('.enabled')) { + disabledAttrs.add(m.path.replace('.enabled', '')); + } + } + return mismatches.filter(m => { + if (!m.path.startsWith('user_settings.attributes.')) return true; + // Keep the .enabled entry itself + if (m.path.endsWith('.enabled')) return true; + // Drop children of disabled attributes + const parentAttr = m.path.replace(/\.[^.]+$/, ''); + return !disabledAttrs.has(parentAttr); + }); +} + +/** + * For social providers that are entirely present/missing, collapse to one line. + */ +function collapseSocialMismatches(mismatches) { + const wholeMissing = new Set(); + for (const m of mismatches) { + if (m.path.startsWith('user_settings.social.') && !m.path.includes('.', 'user_settings.social.x'.length)) { + if ((m.prod && !m.staging) || (!m.prod && m.staging)) { + wholeMissing.add(m.path); + } + } + } + return mismatches.filter(m => { + if (!m.path.startsWith('user_settings.social.')) return true; + // Keep the top-level entry + const parts = m.path.split('.'); + if (parts.length <= 3) return true; + // Drop children of wholly missing providers + const parentPath = parts.slice(0, 3).join('.'); + return !wholeMissing.has(parentPath); + }); +} + +function formatMismatch(m, prefix) { + const field = m.path.slice(prefix.length); + + // Array diff with missing/extra items + if (m.missingOnStaging || m.extraOnStaging) { + const parts = []; + if (m.missingOnStaging?.length) { + parts.push(`missing on staging: ${m.missingOnStaging.join(', ')}`); + } + if (m.extraOnStaging?.length) { + parts.push(`extra on staging: ${m.extraOnStaging.join(', ')}`); + } + return ` ${pad(field, COL_FIELD)} ${parts.join('; ')}`; + } + + // Social provider entirely present/missing + if (prefix === 'user_settings.social.' && !field.includes('.')) { + if (m.prod && !m.staging) { + return ` ${pad(field, COL_FIELD)} ${pad('present', COL_VAL)} missing`; + } + if (!m.prod && m.staging) { + return ` ${pad(field, COL_FIELD)} ${pad('missing', COL_VAL)} present`; + } + } + + const prodVal = formatScalar(m.prod); + const stagingVal = formatScalar(m.staging); + return ` ${pad(field, COL_FIELD)} ${pad(prodVal, COL_VAL)} ${stagingVal}`; +} + +function printReport(name, mismatches) { + if (mismatches.length === 0) { + console.log(`✅ ${name}: matched\n`); + return; + } + + console.log(`❌ ${name} (${mismatches.length} mismatch${mismatches.length === 1 ? '' : 'es'})\n`); + + for (const section of SECTIONS) { + const sectionMismatches = mismatches.filter(m => m.path.startsWith(section.prefix)); + if (sectionMismatches.length === 0) continue; + + console.log(` ${section.label}`); + console.log(` ${pad('', COL_FIELD)} ${pad('prod', COL_VAL)} staging`); + + for (const m of sectionMismatches) { + console.log(formatMismatch(m, section.prefix)); + } + console.log(); + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json'); + for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`); + if (!prodKeys) { + console.error('No production instance keys found.'); + process.exit(0); + } + + const { keys: stagingKeys, errors: stagingErrors } = loadKeys( + 'INTEGRATION_STAGING_INSTANCE_KEYS', + 'integration/.keys.staging.json', + ); + for (const err of stagingErrors) console.error(`⚠️ Staging keys: ${err}`); + if (!stagingKeys) { + console.error('No staging instance keys found. Skipping validation.'); + process.exit(0); + } + + const loadErrorCount = prodErrors.length + stagingErrors.length; + + const pairs = []; + for (const [name, keys] of Object.entries(prodKeys)) { + const stagingName = STAGING_KEY_PREFIX + name; + if (stagingKeys[stagingName]) { + pairs.push({ name, prod: keys, staging: stagingKeys[stagingName] }); + } + } + + if (pairs.length === 0) { + console.log('No production/staging key pairs found. Skipping validation.'); + process.exit(0); + } + + console.log(`Validating ${pairs.length} staging instance pair(s)...\n`); + + let mismatchCount = 0; + let fetchFailCount = 0; + + for (const pair of pairs) { + const prodDomain = parseFapiDomain(pair.prod.pk); + const stagingDomain = parseFapiDomain(pair.staging.pk); + + let prodEnv, stagingEnv; + try { + [prodEnv, stagingEnv] = await Promise.all([fetchEnvironment(prodDomain), fetchEnvironment(stagingDomain)]); + } catch (err) { + fetchFailCount++; + console.log(`⚠️ ${pair.name}: failed to fetch environment`); + console.log(` ${err.message}\n`); + continue; + } + + let mismatches = compareEnvironments(prodEnv, stagingEnv).filter(m => !isIgnored(m.path)); + mismatches = collapseAttributeMismatches(mismatches); + mismatches = collapseSocialMismatches(mismatches); + + if (mismatches.length > 0) mismatchCount++; + printReport(pair.name, mismatches); + } + + const parts = []; + if (mismatchCount > 0) parts.push(`${mismatchCount} mismatched`); + if (fetchFailCount > 0) parts.push(`${fetchFailCount} failed to fetch`); + if (loadErrorCount > 0) parts.push(`${loadErrorCount} key load errors`); + const matchedCount = pairs.length - mismatchCount - fetchFailCount; + if (matchedCount > 0) parts.push(`${matchedCount} matched`); + console.log(`Summary: ${parts.join(', ')} (${pairs.length} total)`); +} + +// Allow importing functions for testing while still being executable +const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url); +if (isDirectRun) { + main().catch(err => { + console.error('Unexpected error:', err); + process.exit(0); + }); +} + +export { + loadKeys, + parseFapiDomain, + fetchEnvironment, + diffObjects, + collapseAttributeMismatches, + collapseSocialMismatches, + compareEnvironments, + main, +}; diff --git a/scripts/validate-staging-instances.test.mjs b/scripts/validate-staging-instances.test.mjs new file mode 100644 index 00000000000..78233054541 --- /dev/null +++ b/scripts/validate-staging-instances.test.mjs @@ -0,0 +1,530 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn(actual.readFileSync) }; +}); + +import { + collapseAttributeMismatches, + collapseSocialMismatches, + diffObjects, + fetchEnvironment, + loadKeys, + main, + parseFapiDomain, +} from './validate-staging-instances.mjs'; + +// ── loadKeys ──────────────────────────────────────────────────────────────── + +describe('loadKeys', () => { + const ENV_VAR = 'TEST_INSTANCE_KEYS'; + + afterEach(() => { + delete process.env[ENV_VAR]; + }); + + it('parses valid JSON from env var', () => { + process.env[ENV_VAR] = JSON.stringify({ + foo: { pk: 'pk_test_abc', sk: 'sk_test_abc' }, + bar: { pk: 'pk_test_def' }, + }); + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toEqual({ + foo: { pk: 'pk_test_abc', sk: 'sk_test_abc' }, + bar: { pk: 'pk_test_def' }, + }); + expect(errors).toEqual([]); + }); + + it('returns error for malformed JSON in env var', () => { + process.env[ENV_VAR] = '{not valid json'; + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/Failed to parse TEST_INSTANCE_KEYS/); + }); + + it('returns error when env var is a JSON array', () => { + process.env[ENV_VAR] = '["a","b"]'; + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/Expected a JSON object/); + }); + + it('returns error when env var is a JSON string', () => { + process.env[ENV_VAR] = '"just a string"'; + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/Expected a JSON object/); + }); + + it('reports entries with missing pk', () => { + process.env[ENV_VAR] = JSON.stringify({ + good: { pk: 'pk_test_abc' }, + bad: { sk: 'sk_only' }, + worse: 'not_an_object', + }); + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toEqual({ good: { pk: 'pk_test_abc' } }); + expect(errors).toHaveLength(2); + expect(errors).toEqual( + expect.arrayContaining([expect.stringContaining('"bad"'), expect.stringContaining('"worse"')]), + ); + }); + + it('returns null keys when all entries have invalid pk', () => { + process.env[ENV_VAR] = JSON.stringify({ + a: { sk: 'no_pk' }, + b: 42, + }); + const { keys, errors } = loadKeys(ENV_VAR, 'nonexistent.json'); + expect(keys).toBeNull(); + expect(errors).toHaveLength(2); + }); + + it('returns null keys with empty errors when file does not exist and no env var', () => { + const { keys, errors } = loadKeys('NONEXISTENT_ENV_VAR', '/tmp/does-not-exist-12345.json'); + expect(keys).toBeNull(); + expect(errors).toEqual([]); + }); +}); + +// ── parseFapiDomain ───────────────────────────────────────────────────────── + +describe('parseFapiDomain', () => { + it('decodes a standard pk to its FAPI domain', () => { + const domain = 'clerk.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + expect(parseFapiDomain(pk)).toBe(domain); + }); + + it('decodes a staging pk', () => { + const domain = 'clerk.staging.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_live_${encoded}`; + expect(parseFapiDomain(pk)).toBe(domain); + }); + + it('handles domains without trailing $', () => { + const domain = 'nodollar.example.com'; + const encoded = Buffer.from(domain).toString('base64'); + const pk = `pk_test_${encoded}`; + expect(parseFapiDomain(pk)).toBe(domain); + }); + + it('handles base64 with underscores in encoded part', () => { + // base64 can contain + and / but we use standard base64 here + const domain = 'special.clerk.dev'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + expect(parseFapiDomain(pk)).toBe(domain); + }); +}); + +// ── diffObjects ───────────────────────────────────────────────────────────── + +describe('diffObjects', () => { + it('returns empty for identical objects', () => { + const obj = { a: 1, b: { c: 'hello' } }; + expect(diffObjects(obj, obj)).toEqual([]); + }); + + it('returns empty for deeply equal objects', () => { + expect(diffObjects({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toEqual([]); + }); + + it('detects scalar mismatch', () => { + const result = diffObjects({ a: 1 }, { a: 2 }); + expect(result).toEqual([{ path: 'a', prod: 1, staging: 2 }]); + }); + + it('detects type mismatch (string vs number)', () => { + const result = diffObjects({ a: '1' }, { a: 1 }); + expect(result).toEqual([{ path: 'a', prod: '1', staging: 1 }]); + }); + + it('detects null vs value mismatch', () => { + const result = diffObjects({ a: null }, { a: 'hello' }); + expect(result).toEqual([{ path: 'a', prod: null, staging: 'hello' }]); + }); + + it('detects undefined vs value mismatch', () => { + const result = diffObjects({ a: 1 }, {}); + expect(result).toEqual([{ path: 'a', prod: 1, staging: undefined }]); + }); + + it('detects nested mismatches with correct paths', () => { + const result = diffObjects({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } }); + expect(result).toEqual([{ path: 'a.b.c', prod: 1, staging: 2 }]); + }); + + it('detects array mismatches with missingOnStaging/extraOnStaging', () => { + const result = diffObjects({ arr: ['a', 'b', 'c'] }, { arr: ['b', 'c', 'd'] }); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('arr'); + expect(result[0].missingOnStaging).toEqual(['a']); + expect(result[0].extraOnStaging).toEqual(['d']); + }); + + it('treats arrays with same elements in different order as equal', () => { + expect(diffObjects({ arr: [1, 2, 3] }, { arr: [3, 1, 2] })).toEqual([]); + }); + + it('handles arrays of objects (non-primitive) without missingOnStaging', () => { + const a = { arr: [{ id: 1 }] }; + const b = { arr: [{ id: 2 }] }; + const result = diffObjects(a, b); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('arr'); + // Non-primitive arrays don't get missingOnStaging/extraOnStaging + expect(result[0].missingOnStaging).toBeUndefined(); + expect(result[0].extraOnStaging).toBeUndefined(); + }); + + it('uses root path when provided', () => { + const result = diffObjects({ x: 1 }, { x: 2 }, 'root'); + expect(result).toEqual([{ path: 'root.x', prod: 1, staging: 2 }]); + }); + + it('detects keys present only in one side', () => { + const a = { shared: 1, onlyProd: 'yes' }; + const b = { shared: 1, onlyStaging: 'yes' }; + const result = diffObjects(a, b); + expect(result).toEqual( + expect.arrayContaining([ + { path: 'onlyProd', prod: 'yes', staging: undefined }, + { path: 'onlyStaging', prod: undefined, staging: 'yes' }, + ]), + ); + }); + + it('returns empty for two identical primitive values at root', () => { + expect(diffObjects(42, 42)).toEqual([]); + }); + + it('detects mismatch for two different primitive values at root', () => { + expect(diffObjects(true, false)).toEqual([{ path: '', prod: true, staging: false }]); + }); +}); + +// ── collapseAttributeMismatches ───────────────────────────────────────────── + +describe('collapseAttributeMismatches', () => { + it('collapses child diffs when .enabled differs', () => { + const mismatches = [ + { path: 'user_settings.attributes.phone_number.enabled', prod: true, staging: false }, + { path: 'user_settings.attributes.phone_number.first_factors', prod: ['phone_code'], staging: [] }, + { path: 'user_settings.attributes.phone_number.verifications', prod: ['phone_code'], staging: [] }, + ]; + const result = collapseAttributeMismatches(mismatches); + expect(result).toEqual([ + { path: 'user_settings.attributes.phone_number.enabled', prod: true, staging: false }, + ]); + }); + + it('keeps child diffs when .enabled does NOT differ', () => { + const mismatches = [ + { path: 'user_settings.attributes.email.first_factors', prod: ['email_code'], staging: ['email_link'] }, + ]; + const result = collapseAttributeMismatches(mismatches); + expect(result).toEqual(mismatches); + }); + + it('does not collapse non-attribute mismatches', () => { + const mismatches = [ + { path: 'auth_config.single_session_mode', prod: true, staging: false }, + { path: 'user_settings.attributes.username.enabled', prod: true, staging: false }, + { path: 'user_settings.attributes.username.required', prod: true, staging: false }, + ]; + const result = collapseAttributeMismatches(mismatches); + expect(result).toEqual([ + { path: 'auth_config.single_session_mode', prod: true, staging: false }, + { path: 'user_settings.attributes.username.enabled', prod: true, staging: false }, + ]); + }); + + it('returns empty array for empty input', () => { + expect(collapseAttributeMismatches([])).toEqual([]); + }); + + it('collapses multiple disabled attributes independently', () => { + const mismatches = [ + { path: 'user_settings.attributes.phone_number.enabled', prod: true, staging: false }, + { path: 'user_settings.attributes.phone_number.verifications', prod: ['a'], staging: [] }, + { path: 'user_settings.attributes.username.enabled', prod: false, staging: true }, + { path: 'user_settings.attributes.username.required', prod: false, staging: true }, + { path: 'user_settings.attributes.email.first_factors', prod: ['x'], staging: ['y'] }, + ]; + const result = collapseAttributeMismatches(mismatches); + expect(result).toEqual([ + { path: 'user_settings.attributes.phone_number.enabled', prod: true, staging: false }, + { path: 'user_settings.attributes.username.enabled', prod: false, staging: true }, + { path: 'user_settings.attributes.email.first_factors', prod: ['x'], staging: ['y'] }, + ]); + }); +}); + +// ── collapseSocialMismatches ──────────────────────────────────────────────── + +describe('collapseSocialMismatches', () => { + it('collapses child diffs for wholly missing social provider', () => { + const mismatches = [ + { path: 'user_settings.social.google', prod: { enabled: true }, staging: undefined }, + { path: 'user_settings.social.google.enabled', prod: true, staging: undefined }, + { path: 'user_settings.social.google.strategy', prod: 'oauth_google', staging: undefined }, + ]; + const result = collapseSocialMismatches(mismatches); + expect(result).toEqual([ + { path: 'user_settings.social.google', prod: { enabled: true }, staging: undefined }, + ]); + }); + + it('collapses child diffs for extra social provider on staging', () => { + const mismatches = [ + { path: 'user_settings.social.github', prod: undefined, staging: { enabled: true } }, + { path: 'user_settings.social.github.enabled', prod: undefined, staging: true }, + ]; + const result = collapseSocialMismatches(mismatches); + expect(result).toEqual([ + { path: 'user_settings.social.github', prod: undefined, staging: { enabled: true } }, + ]); + }); + + it('keeps child diffs when both prod and staging have the provider', () => { + const mismatches = [ + { path: 'user_settings.social.facebook', prod: { enabled: true }, staging: { enabled: false } }, + { path: 'user_settings.social.facebook.enabled', prod: true, staging: false }, + ]; + const result = collapseSocialMismatches(mismatches); + // Both prod and staging are truthy, so not collapsed + expect(result).toEqual(mismatches); + }); + + it('does not affect non-social mismatches', () => { + const mismatches = [ + { path: 'auth_config.session_token_ttl', prod: 3600, staging: 7200 }, + ]; + const result = collapseSocialMismatches(mismatches); + expect(result).toEqual(mismatches); + }); + + it('returns empty array for empty input', () => { + expect(collapseSocialMismatches([])).toEqual([]); + }); +}); + +// ── fetchEnvironment ──────────────────────────────────────────────────────── + +describe('fetchEnvironment', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns parsed JSON on success', async () => { + const mockEnv = { auth_config: { id: '123' } }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockEnv), + }); + + const result = await fetchEnvironment('clerk.example.com'); + expect(result).toEqual(mockEnv); + expect(globalThis.fetch).toHaveBeenCalledWith('https://clerk.example.com/v1/environment', expect.any(Object)); + }); + + it('throws on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect(fetchEnvironment('clerk.example.com')).rejects.toThrow(/Failed to fetch.*404/); + }); + + it('throws on network error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure')); + + await expect(fetchEnvironment('clerk.example.com')).rejects.toThrow('Network failure'); + }); +}); + +// ── main orchestration ────────────────────────────────────────────────────── + +describe('main', () => { + let consoleLogs; + let consoleErrors; + let exitCode; + + beforeEach(() => { + consoleLogs = []; + consoleErrors = []; + exitCode = undefined; + + vi.spyOn(console, 'log').mockImplementation((...args) => consoleLogs.push(args.join(' '))); + vi.spyOn(console, 'error').mockImplementation((...args) => consoleErrors.push(args.join(' '))); + vi.spyOn(process, 'exit').mockImplementation(code => { + exitCode = code; + throw new Error(`process.exit(${code})`); + }); + + // Clean up env vars + delete process.env.INTEGRATION_INSTANCE_KEYS; + delete process.env.INTEGRATION_STAGING_INSTANCE_KEYS; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exits gracefully when no production keys found', async () => { + // Mock file reads to throw (simulating missing files) + readFileSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + await expect(main()).rejects.toThrow('process.exit(0)'); + expect(consoleErrors.some(m => m.includes('No production instance keys found'))).toBe(true); + expect(exitCode).toBe(0); + }); + + it('exits gracefully when no staging keys found', async () => { + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk: 'pk_test_abc' }, + }); + // Mock file reads to throw for staging keys file + readFileSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + await expect(main()).rejects.toThrow('process.exit(0)'); + expect(consoleErrors.some(m => m.includes('No staging instance keys found'))).toBe(true); + expect(exitCode).toBe(0); + }); + + it('exits when no pairs match', async () => { + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk: 'pk_test_abc' }, + }); + process.env.INTEGRATION_STAGING_INSTANCE_KEYS = JSON.stringify({ + unrelated: { pk: 'pk_test_def' }, + }); + + await expect(main()).rejects.toThrow('process.exit(0)'); + expect(consoleLogs.some(m => m.includes('No production/staging key pairs found'))).toBe(true); + }); + + it('reports matched pairs when environments are identical', async () => { + const domain = 'clerk.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk }, + }); + process.env.INTEGRATION_STAGING_INSTANCE_KEYS = JSON.stringify({ + 'clerkstage-myapp': { pk }, + }); + + const envResponse = { + auth_config: { session_token_ttl: 3600 }, + organization_settings: { enabled: true }, + user_settings: { attributes: {}, social: {}, sign_in: {}, sign_up: {}, password_settings: {} }, + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve(envResponse), + }); + + await main(); + expect(consoleLogs.some(m => m.includes('myapp: matched'))).toBe(true); + expect(consoleLogs.some(m => m.includes('1 matched'))).toBe(true); + }); + + it('reports mismatches when environments differ', async () => { + const domain = 'clerk.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk }, + }); + process.env.INTEGRATION_STAGING_INSTANCE_KEYS = JSON.stringify({ + 'clerkstage-myapp': { pk }, + }); + + const prodEnv = { + auth_config: { single_session_mode: true }, + organization_settings: {}, + user_settings: { attributes: {}, social: {}, sign_in: {}, sign_up: {}, password_settings: {} }, + }; + const stagingEnv = { + auth_config: { single_session_mode: false }, + organization_settings: {}, + user_settings: { attributes: {}, social: {}, sign_in: {}, sign_up: {}, password_settings: {} }, + }; + + let callCount = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + callCount++; + const env = callCount % 2 === 1 ? prodEnv : stagingEnv; + return Promise.resolve({ ok: true, json: () => Promise.resolve(env) }); + }); + + await main(); + expect(consoleLogs.some(m => m.includes('myapp') && m.includes('mismatch'))).toBe(true); + expect(consoleLogs.some(m => m.includes('1 mismatched'))).toBe(true); + }); + + it('reports fetch failures in summary', async () => { + const domain = 'clerk.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk }, + }); + process.env.INTEGRATION_STAGING_INSTANCE_KEYS = JSON.stringify({ + 'clerkstage-myapp': { pk }, + }); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('timeout')); + + await main(); + expect(consoleLogs.some(m => m.includes('failed to fetch environment'))).toBe(true); + expect(consoleLogs.some(m => m.includes('1 failed to fetch'))).toBe(true); + }); + + it('reports key load errors in summary', async () => { + const domain = 'clerk.example.com'; + const encoded = Buffer.from(domain + '$').toString('base64'); + const pk = `pk_test_${encoded}`; + + process.env.INTEGRATION_INSTANCE_KEYS = JSON.stringify({ + myapp: { pk }, + bad_entry: 'not_an_object', + }); + process.env.INTEGRATION_STAGING_INSTANCE_KEYS = JSON.stringify({ + 'clerkstage-myapp': { pk }, + }); + + const envResponse = { + auth_config: {}, + organization_settings: {}, + user_settings: { attributes: {}, social: {}, sign_in: {}, sign_up: {}, password_settings: {} }, + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve(envResponse), + }); + + await main(); + expect(consoleErrors.some(m => m.includes('bad_entry'))).toBe(true); + expect(consoleLogs.some(m => m.includes('1 key load errors'))).toBe(true); + }); +}); diff --git a/scripts/vitest.config.mjs b/scripts/vitest.config.mjs new file mode 100644 index 00000000000..3b42802123c --- /dev/null +++ b/scripts/vitest.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['**/*.test.mjs'], + }, +}); diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index fed8351e8fc..0178afab4d0 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -1,3 +1,3 @@ import { defineWorkspace } from 'vitest/config'; -export default defineWorkspace(['./packages/*/vitest.config.{mts,mjs,js,ts}']); +export default defineWorkspace(['./packages/*/vitest.config.{mts,mjs,js,ts}', './scripts/vitest.config.mjs']);