Files
shiroko-monitor/scripts/verify-uptimerobot.cjs
T
tamakyi 3db3ce570a chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
2026-10-13 18:31:58 +08:00

194 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});