Files
gongxue-base/scripts/remote-tenant-cors-smoke.js
2026-07-12 19:26:57 +08:00

169 lines
6.7 KiB
JavaScript

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 };