/** * shiroko-monitor UptimeRobot 集成验证(开发用) * * 覆盖: * A. 映射单测(无网络):用合成 payload 校验状态/延迟/状态码/心率/聚合比率/元信息,且不泄露 Key * B. 真实 Key 冒烟(需 UPTIMEROBOT_API_KEY,未设置则跳过) * C. UptimeRobot 兼容只读接口 /api/v2/getMonitors * D. 失败路径:Key 无效时只记录错误、不把任何目标误判为离线 * * 用法:UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs */ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'uptimerobot.js'); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-ur-')); const LIVE_KEY = process.env.UPTIMEROBOT_API_KEY || ''; const results = []; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function check(name, pass, detail) { results.push({ name, pass }); console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); } async function waitForHttp(url, timeoutMs = 8000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(url, { cache: 'no-store' }); return true; } catch { await sleep(200); } } return false; } function runOnce(args) { return new Promise((resolve) => { const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; child.stdout.on('data', (d) => (out += d)); child.stderr.on('data', (d) => (out += d)); child.on('exit', (code) => resolve({ code, out })); }); } // 合成的 getMonitors 响应:结构照搬真实字段,但内容全部虚构 const FIXTURE = { stat: 'ok', pagination: { offset: 0, limit: 50, total: 3 }, monitors: [ { id: 111111111, friendly_name: '演示站点 A', url: 'https://a.example.test', type: 1, interval: 300, status: 2, create_datetime: 1700000000, custom_uptime_ratio: '100.000-99.500-99.900-99.950', custom_down_durations: '0-100-200-300', average_response_time: 250.5, response_times: [ { datetime: 1791880000, value: 300 }, { datetime: 1791879700, value: 200 }, { datetime: 1791879400, value: 0 }, ], logs: [ { type: 2, datetime: 1791879700, duration: 300, reason: { code: '200', detail: 'OK' } }, { type: 1, datetime: 1791879400, duration: 300, reason: { code: '500', detail: 'Internal Server Error' } }, ], }, { id: 222222222, friendly_name: '演示端口 B', url: 'b.example.test', type: 4, port: 22, interval: 300, status: 9, custom_uptime_ratio: '0.000-0.000-0.000-0.000', custom_down_durations: '86400-86400-86400-86400', average_response_time: 0, response_times: [], logs: [{ type: 1, datetime: 1791000000, duration: 0, reason: { code: '403', detail: 'Forbidden' } }], }, { id: 333333333, friendly_name: '演示暂停项 C', url: 'https://c.example.test', type: 1, interval: 300, status: 0, custom_uptime_ratio: '0.000-0.000-0.000-0.000', response_times: [], logs: [], }, ], }; async function main() { const { mapUptimeRobotMonitors } = await import(`file://${LIB.replace(/\\/g, '/')}`); // ---------- A. 映射单测 ---------- console.log('\n[A] 映射单测(合成 payload,无网络)'); const mapped = mapUptimeRobotMonitors(FIXTURE, { group: 'UR 测试', now: 1791880300000 }); const [a, b, c] = mapped; check('监控数量与 id 前缀', mapped.length === 3 && a.id === 'ur-111111111', `${mapped.length} 个,首个 id=${a.id}`); check('status 映射:2→up / 9→down / 0→pending', a.status === 'up' && b.status === 'down' && c.status === 'pending', `${a.status}/${b.status}/${c.status}`); check('延迟取最新一次样本', a.latency === 300 && b.latency === 0, `A=${a.latency}ms B=${b.latency}ms`); check('故障时取故障日志里的 HTTP 状态码', b.code === 403 && a.code === null, `B.code=${b.code}`); check('detail 含 UR 侧原因', b.detail === 'UptimeRobot:403 Forbidden', b.detail); check('心率来自 response_times 且按时间升序', a.history.length === 3 && a.history[0].ms === 0 && a.history[2].ms === 300, JSON.stringify(a.history.map((h) => h.ms))); check('离线目标的心率末尾补一条离线记录', b.history.length === 1 && b.history[0].ok === false, `${b.history.length} 条`); check('聚合比率解析(1/7/30/90)', a.uptimeRatios['90'] === 99.95 && a.downSeconds['30'] === 200, JSON.stringify(a.uptimeRatios)); check('端口型目标拼出 host:port 形式', b.url === 'b.example.test:22', b.url); check('来源标识与协议标签', a.source === 'uptimerobot' && a.sourceLabel === 'HTTP' && b.sourceLabel === 'PORT', `${a.sourceLabel}/${b.sourceLabel}`); check('映射结果不含 API Key 字段', !JSON.stringify(mapped).toLowerCase().includes('apikey'), '无泄露'); // ---------- C. 兼容接口 ---------- console.log('\n[C] UptimeRobot 兼容只读接口'); const cfg = path.join(TMP, 'monitors.json'); const out = path.join(TMP, 'status.json'); fs.writeFileSync(cfg, JSON.stringify({ settings: { title: 't' }, monitors: [ { id: 'ok-site', name: '正常站点', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 }, ], }, null, 2)); const child = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${cfg}`, `--out=${out}`], { env: { ...process.env, PORT: '8891' }, stdio: ['ignore', 'pipe', 'pipe'] }); await waitForHttp('http://127.0.0.1:8891/api/status'); const compat = await fetch('http://127.0.0.1:8891/api/v2/getMonitors').then((r) => r.json()); const first = compat.monitors?.[0] ?? {}; check('返回 stat=ok 与 pagination', compat.stat === 'ok' && compat.pagination?.total === 1, JSON.stringify(compat.pagination)); check('字段与 UptimeRobot 对齐', ['id', 'friendly_name', 'url', 'type', 'status', 'interval', 'custom_uptime_ratio', 'average_response_time', 'response_times'].every((k) => k in first), Object.keys(first).join(',')); check('id 为数字、type/status 编码一致', typeof first.id === 'number' && first.type === 1 && [1, 2, 9].includes(first.status), `id=${first.id} type=${first.type} status=${first.status}`); check('custom_uptime_ratio 为 1-7-30-90 四位三位小数', /^\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}$/.test(first.custom_uptime_ratio), first.custom_uptime_ratio); child.kill(); // ---------- D. 失败路径:Key 无效不得误判目标状态 ---------- console.log('\n[D] 失败路径(无效 Key)'); const badCfg = path.join(TMP, 'bad.json'); const badOut = path.join(TMP, 'bad-status.json'); fs.writeFileSync(badCfg, JSON.stringify({ settings: { title: 't', uptimeRobot: { enabled: true, apiKey: 'ur000000-this-key-is-not-valid' } }, monitors: [ { id: 'only-site', name: '唯一目标', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 }, ], }, null, 2)); const r = await runOnce([`--once`, `--config=${badCfg}`, `--local-config=${badCfg}`, `--out=${badOut}`]); const badSnap = JSON.parse(fs.readFileSync(badOut, 'utf8')); check('同步失败被记录为 ok:false', badSnap.sources.uptimerobot.ok === false, String(badSnap.sources.uptimerobot.error).slice(0, 60)); check('同步失败不影响本地目标(无目标被误判为离线)', badSnap.stats.total === 1 && badSnap.stats.up === 1, `total=${badSnap.stats.total} up=${badSnap.stats.up}`); check('未创建任何 UptimeRobot 镜像目标', Object.values(badSnap.monitors).every((m) => m.meta?.source !== 'uptimerobot'), '无镜像目标'); // ---------- B. 真实 Key 冒烟 ---------- console.log('\n[B] 真实 Key 冒烟'); if (!LIVE_KEY) { console.log(' - 跳过(未设置 UPTIMEROBOT_API_KEY)'); } else { const liveCfg = path.join(TMP, 'live.json'); const liveOut = path.join(TMP, 'live-status.json'); fs.writeFileSync(liveCfg, JSON.stringify({ settings: { title: 't', uptimeRobot: { enabled: true, apiKey: LIVE_KEY, pollIntervalSec: 300, group: 'UptimeRobot' } }, monitors: [], }, null, 2)); await runOnce([`--once`, `--config=${liveCfg}`, `--local-config=${liveCfg}`, `--out=${liveOut}`]); const live = JSON.parse(fs.readFileSync(liveOut, 'utf8')); const mirrors = Object.entries(live.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot'); check('真实账号同步成功', live.sources.uptimerobot.ok === true, `count=${live.sources.uptimerobot.count}`); check('拉到监控并全部映射', mirrors.length > 0 && mirrors.every(([, m]) => ['up', 'down', 'pending'].includes(m.status)), `${mirrors.length} 个`); check('每个镜像目标都带展示元信息', mirrors.every(([, m]) => m.meta.name && Array.isArray(m.history) && m.uptimeRatios), 'name/history/uptimeRatios 齐备'); check('快照中不含 API Key', !JSON.stringify(live).includes(LIVE_KEY.slice(-12)), '无泄露'); for (const [id, m] of mirrors) console.log(` · ${id.padEnd(14)} ${m.status.padEnd(7)} ${String(m.latency ?? '-').padStart(5)}ms UR90天=${m.uptimeRatios?.['90']} ${m.meta.name}`); } fs.rmSync(TMP, { recursive: true, force: true }); const failed = results.filter((x) => !x.pass); console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); if (failed.length) process.exit(1); } main().catch((err) => { console.error('FAILED:', err.stack || err.message); fs.rmSync(TMP, { recursive: true, force: true }); process.exit(1); });