forked from wangziqi/gongxue-base
169 lines
6.7 KiB
JavaScript
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 };
|