import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const DEFAULT_TIMEOUT_MS = 10_000; function envString(env, key, fallback = '') { return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; } function envNumber(env, key, fallback) { const value = Number(envString(env, key)); return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback; } function normalizeBaseUrl(value) { return value.replace(/\/+$/, ''); } function normalizeOrigin(value, key) { let parsed; try { parsed = new URL(value); } catch { throw new Error(`${key} must be a valid URL origin`); } if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { throw new Error(`${key} must contain only scheme, host and optional port`); } return parsed.origin; } function parseArgs(argv) { const options = { json: argv.includes('--json'), quiet: argv.includes('--quiet'), writePath: '' }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--write') { options.writePath = argv[index + 1] || ''; index += 1; } else if (arg.startsWith('--write=')) { options.writePath = arg.slice('--write='.length); } } return options; } function buildConfig(env = process.env) { const apiBaseUrl = envString(env, 'TENANT_CORS_API_BASE_URL', envString(env, 'API_BASE', '')); const activeOrigin = envString(env, 'TENANT_CORS_ACTIVE_ORIGIN'); const disabledOrigin = envString(env, 'TENANT_CORS_DISABLED_ORIGIN'); const unknownOrigin = envString(env, 'TENANT_CORS_UNKNOWN_ORIGIN'); const missing = []; if (!apiBaseUrl) missing.push('TENANT_CORS_API_BASE_URL'); if (!activeOrigin) missing.push('TENANT_CORS_ACTIVE_ORIGIN'); if (!disabledOrigin) missing.push('TENANT_CORS_DISABLED_ORIGIN'); if (!unknownOrigin) missing.push('TENANT_CORS_UNKNOWN_ORIGIN'); if (missing.length > 0) throw new Error(`Missing required remote tenant CORS smoke env: ${missing.join(', ')}`); return { apiBaseUrl: normalizeBaseUrl(apiBaseUrl), activeOrigin: normalizeOrigin(activeOrigin, 'TENANT_CORS_ACTIVE_ORIGIN'), disabledOrigin: normalizeOrigin(disabledOrigin, 'TENANT_CORS_DISABLED_ORIGIN'), unknownOrigin: normalizeOrigin(unknownOrigin, 'TENANT_CORS_UNKNOWN_ORIGIN'), timeoutMs: envNumber(env, 'TENANT_CORS_TIMEOUT_MS', DEFAULT_TIMEOUT_MS), }; } async function request(config, { origin = '', method = 'OPTIONS', pathName = '/api/tenant/resolve' } = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); try { const response = await fetch(new URL(pathName, config.apiBaseUrl), { method, headers: origin ? { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'content-type,x-tenant-code', } : {}, signal: controller.signal, }); const text = await response.text(); let payload = {}; if (text.trim()) { try { payload = JSON.parse(text); } catch { payload = { raw: text.slice(0, 300) }; } } return { status: response.status, allowOrigin: response.headers.get('access-control-allow-origin') || '', vary: response.headers.get('vary') || '', payload, }; } finally { clearTimeout(timeout); } } function assert(condition, message, detail = {}) { if (condition) return; const error = new Error(message); error.detail = detail; throw error; } async function runRemoteTenantCorsSmoke(inputConfig, options = {}) { const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env); const active = await request(config, { origin: config.activeOrigin }); assert(active.status === 204, 'active tenant Origin preflight must return HTTP 204', active); assert(active.allowOrigin === config.activeOrigin, 'active tenant Origin must be echoed in access-control-allow-origin', active); assert(/(?:^|,)\s*origin\s*(?:,|$)/i.test(active.vary), 'active tenant Origin response must vary by Origin', active); const disabled = await request(config, { origin: config.disabledOrigin }); assert(disabled.status === 403, 'disabled tenant Origin preflight must return HTTP 403', disabled); assert(!disabled.allowOrigin, 'disabled tenant Origin must not receive access-control-allow-origin', disabled); assert(disabled.payload?.code === 'CORS_ORIGIN_DENIED', 'disabled tenant Origin must fail with CORS_ORIGIN_DENIED', disabled); const unknown = await request(config, { origin: config.unknownOrigin }); assert(unknown.status === 403, 'unknown tenant Origin preflight must return HTTP 403', unknown); assert(!unknown.allowOrigin, 'unknown tenant Origin must not receive access-control-allow-origin', unknown); assert(unknown.payload?.code === 'CORS_ORIGIN_DENIED', 'unknown tenant Origin must fail with CORS_ORIGIN_DENIED', unknown); const health = await request(config, { method: 'GET', pathName: '/health' }); assert(health.status === 200, 'health request without Origin must remain available', health); assert(health.payload?.ok === true, 'health request without Origin must return ok=true', health); const summary = { failed: 0, activeTenantOriginAllowed: true, unknownOriginDenied: true, disabledOriginDenied: true, noOriginHealthAllowed: true, statuses: { active: active.status, disabled: disabled.status, unknown: unknown.status, health: health.status, }, }; if (!options.quiet) console.log('[PASS] remote dynamic tenant CORS smoke'); return summary; } async function main() { const options = parseArgs(process.argv.slice(2)); try { const summary = await runRemoteTenantCorsSmoke(buildConfig(), { quiet: options.quiet || options.json }); if (options.writePath) { const resolved = path.resolve(process.cwd(), options.writePath); fs.mkdirSync(path.dirname(resolved), { recursive: true }); fs.writeFileSync(resolved, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); } if (options.json) console.log(JSON.stringify(summary, null, 2)); } catch (error) { const failure = { failed: 1, error: error.message, detail: error.detail || undefined }; if (options.json) console.log(JSON.stringify(failure, null, 2)); else { console.error(error.message); if (error.detail) console.error(JSON.stringify(error.detail, null, 2)); } process.exitCode = 1; } } const currentFile = fileURLToPath(import.meta.url); if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main(); export { buildConfig, runRemoteTenantCorsSmoke };