forked from wangziqi/gongxue-base
275 lines
9.4 KiB
JavaScript
275 lines
9.4 KiB
JavaScript
import { createInterface } from 'node:readline/promises';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { stdin as input, stdout as output, stderr } from 'node:process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 15_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 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 normalizeBaseUrl(value) {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function maskPhone(value) {
|
|
return value.length >= 7 ? `${value.slice(0, 3)}****${value.slice(-4)}` : '<redacted>';
|
|
}
|
|
|
|
function redactToken(value) {
|
|
if (!value) return '<empty>';
|
|
if (value.length <= 16) return '<redacted>';
|
|
return `${value.slice(0, 6)}...${value.slice(-6)}`;
|
|
}
|
|
|
|
function buildConfig(env = process.env) {
|
|
const apiBaseUrl = envString(env, 'SMS_SMOKE_API_BASE_URL', envString(env, 'API_BASE', ''));
|
|
const tenantId = envString(env, 'SMS_SMOKE_TENANT_ID', envString(env, 'TENANT_ID', ''));
|
|
const phone = envString(env, 'SMS_SMOKE_PHONE');
|
|
const bindPhone = envString(env, 'SMS_SMOKE_BIND_PHONE');
|
|
const origin = envString(env, 'SMS_SMOKE_ORIGIN', 'https://admin.tjszsb.com');
|
|
const purpose = envString(env, 'SMS_SMOKE_PURPOSE', 'login');
|
|
const code = envString(env, 'SMS_SMOKE_CODE');
|
|
const bindCode = envString(env, 'SMS_SMOKE_BIND_CODE');
|
|
|
|
const missing = [];
|
|
if (!apiBaseUrl) missing.push('SMS_SMOKE_API_BASE_URL');
|
|
if (!tenantId) missing.push('SMS_SMOKE_TENANT_ID');
|
|
if (!phone) missing.push('SMS_SMOKE_PHONE');
|
|
if (missing.length > 0) {
|
|
throw new Error(`Missing required remote SMS smoke env: ${missing.join(', ')}`);
|
|
}
|
|
|
|
return {
|
|
apiBaseUrl: normalizeBaseUrl(apiBaseUrl),
|
|
tenantId,
|
|
phone,
|
|
bindPhone,
|
|
origin,
|
|
purpose,
|
|
code,
|
|
bindCode,
|
|
timeoutMs: envNumber(env, 'SMS_SMOKE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
|
|
skipSend: ['1', 'true', 'yes', 'on'].includes(envString(env, 'SMS_SMOKE_SKIP_SEND').toLowerCase()),
|
|
skipMe: ['1', 'true', 'yes', 'on'].includes(envString(env, 'SMS_SMOKE_SKIP_ME').toLowerCase()),
|
|
};
|
|
}
|
|
|
|
async function requestJson(config, path, { method = 'GET', body, token } = {}) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
const url = new URL(path, config.apiBaseUrl);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
accept: 'application/json',
|
|
origin: config.origin,
|
|
'content-type': 'application/json',
|
|
'x-tenant-id': config.tenantId,
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
const text = await response.text();
|
|
let payload = {};
|
|
if (text.trim()) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = { raw: text.slice(0, 500) };
|
|
}
|
|
}
|
|
return { status: response.status, ok: response.ok, payload };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function assertOk(name, response) {
|
|
if (response.ok) return;
|
|
const error = new Error(`${name} failed with HTTP ${response.status}`);
|
|
error.response = response;
|
|
throw error;
|
|
}
|
|
|
|
async function promptCode(config) {
|
|
if (config.code) return config.code;
|
|
const readline = createInterface({ input, output: config.jsonOutput ? stderr : output });
|
|
try {
|
|
return (await readline.question('Enter received SMS verification code: ')).trim();
|
|
} finally {
|
|
readline.close();
|
|
}
|
|
}
|
|
|
|
async function promptBindCode(config) {
|
|
if (config.bindCode) return config.bindCode;
|
|
const readline = createInterface({ input, output: config.jsonOutput ? stderr : output });
|
|
try {
|
|
return (await readline.question('Enter received bind_phone SMS verification code: ')).trim();
|
|
} finally {
|
|
readline.close();
|
|
}
|
|
}
|
|
|
|
async function runRemoteSmsLoginSmoke(inputConfig, options = {}) {
|
|
const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env);
|
|
if (!options.quiet) {
|
|
console.log(`Remote SMS login smoke target: ${config.apiBaseUrl}`);
|
|
console.log(`Origin: ${config.origin}`);
|
|
console.log(`Tenant: ${config.tenantId}`);
|
|
console.log(`Phone: ${maskPhone(config.phone)}`);
|
|
}
|
|
|
|
if (!config.skipSend) {
|
|
const send = await requestJson(config, '/api/auth/sms/send', {
|
|
method: 'POST',
|
|
body: { phone: config.phone, purpose: config.purpose },
|
|
});
|
|
assertOk('sms.send', send);
|
|
if (!options.quiet) {
|
|
console.log(`PASS sms.send provider=${send.payload?.item?.provider || 'unknown'} expireIn=${send.payload?.expireIn || '-'} cooldown=${send.payload?.cooldown || '-'}`);
|
|
}
|
|
config.sendProvider = send.payload?.item?.provider || '';
|
|
}
|
|
|
|
const code = await promptCode(config);
|
|
if (!code) throw new Error('Missing SMS verification code');
|
|
|
|
const verify = await requestJson(config, '/api/auth/sms/verify', {
|
|
method: 'POST',
|
|
body: { phone: config.phone, code, purpose: config.purpose },
|
|
});
|
|
assertOk('sms.verify', verify);
|
|
const token = verify.payload?.session?.token || '';
|
|
if (!token) throw new Error('sms.verify succeeded but did not return session.token');
|
|
if (!options.quiet) {
|
|
console.log(`PASS sms.verify user=${verify.payload?.user?.id || verify.payload?.item?.userId || 'unknown'} token=${redactToken(token)}`);
|
|
}
|
|
|
|
if (!config.skipMe) {
|
|
const me = await requestJson(config, '/api/auth/me', { token });
|
|
assertOk('auth.me', me);
|
|
if (!options.quiet) console.log(`PASS auth.me user=${me.payload?.user?.id || me.payload?.item?.userId || 'unknown'}`);
|
|
}
|
|
|
|
let boundPhone = null;
|
|
let bindProvider = '';
|
|
if (config.bindPhone) {
|
|
const sendBind = await requestJson(config, '/api/auth/sms/send', {
|
|
method: 'POST',
|
|
token,
|
|
body: { phone: config.bindPhone, purpose: 'bind_phone' },
|
|
});
|
|
assertOk('sms.bind.send', sendBind);
|
|
bindProvider = sendBind.payload?.item?.provider || '';
|
|
if (!options.quiet) {
|
|
console.log(`PASS sms.bind.send provider=${bindProvider || 'unknown'} phone=${maskPhone(config.bindPhone)}`);
|
|
}
|
|
|
|
const bindCode = await promptBindCode(config);
|
|
if (!bindCode) throw new Error('Missing bind_phone SMS verification code');
|
|
|
|
const bind = await requestJson(config, '/api/auth/phone/bind', {
|
|
method: 'POST',
|
|
token,
|
|
body: { phone: config.bindPhone, code: bindCode, purpose: 'bind_phone' },
|
|
});
|
|
assertOk('auth.phone.bind', bind);
|
|
boundPhone = bind.payload?.user?.phone || config.bindPhone;
|
|
if (!options.quiet) {
|
|
console.log(`PASS auth.phone.bind phone=${maskPhone(boundPhone)} phoneChanged=${bind.payload?.phoneChanged ?? '-'}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionToken: token,
|
|
user: verify.payload?.user || verify.payload?.item || null,
|
|
boundPhone,
|
|
summary: {
|
|
failed: 0,
|
|
provider: config.sendProvider || 'unknown',
|
|
loginVerified: true,
|
|
authMe: !config.skipMe,
|
|
bindProvider: bindProvider || undefined,
|
|
bindVerified: Boolean(boundPhone),
|
|
phoneMasked: maskPhone(config.phone),
|
|
bindPhoneMasked: config.bindPhone ? maskPhone(config.bindPhone) : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
try {
|
|
const machineOutput = options.json || Boolean(options.writePath);
|
|
const result = await runRemoteSmsLoginSmoke({ ...buildConfig(), jsonOutput: machineOutput }, { quiet: options.quiet || machineOutput });
|
|
if (options.writePath) {
|
|
const resolvedPath = path.resolve(process.cwd(), options.writePath);
|
|
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
fs.writeFileSync(resolvedPath, `${JSON.stringify(result.summary, null, 2)}\n`, 'utf8');
|
|
}
|
|
if (options.json) console.log(JSON.stringify(result.summary, null, 2));
|
|
} catch (error) {
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ failed: 1, error: error.message }, null, 2));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.error(error.message);
|
|
if (error.response?.payload) console.error(JSON.stringify(error.response.payload, null, 2));
|
|
if (/Missing required remote SMS smoke env/.test(error.message)) {
|
|
console.error(`
|
|
Required example:
|
|
SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com
|
|
SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001
|
|
SMS_SMOKE_PHONE=13800138000
|
|
|
|
Optional:
|
|
SMS_SMOKE_ORIGIN=https://admin.tjszsb.com
|
|
SMS_SMOKE_CODE=<code-you-received>
|
|
SMS_SMOKE_BIND_PHONE=<another-phone-to-bind>
|
|
SMS_SMOKE_BIND_CODE=<bind-code-you-received>
|
|
SMS_SMOKE_SKIP_SEND=true
|
|
--json
|
|
--write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json
|
|
`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const currentFile = fileURLToPath(import.meta.url);
|
|
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
|
await main();
|
|
}
|
|
|
|
export { buildConfig, runRemoteSmsLoginSmoke };
|