mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 17:33:56 +08:00
410 lines
20 KiB
JavaScript
410 lines
20 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const ddnsScript = path.join(root, 'root/usr/lib/minigate/ddns.sh');
|
|
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
|
|
const model = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
|
|
const view = fs.readFileSync(path.join(root, 'luasrc/view/minigate/ddns_history.htm'), 'utf8');
|
|
|
|
function runHistory(script, options = {}) {
|
|
const historyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-history-'));
|
|
const command = [
|
|
'set -eu',
|
|
'export MINIGATE_DDNS_LIBRARY_ONLY=1',
|
|
`export MINIGATE_HISTORY_DIR=${JSON.stringify(historyDir)}`,
|
|
`export MINIGATE_CACHE_DIR=${JSON.stringify(historyDir)}`,
|
|
`export MINIGATE_HISTORY_WINDOW=${options.window || 86400}`,
|
|
`. ${JSON.stringify(ddnsScript)}`,
|
|
script
|
|
].join('\n');
|
|
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
return { historyDir, output: result.stdout.trim() };
|
|
}
|
|
|
|
function readRows(file) {
|
|
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
|
|
}
|
|
|
|
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0, currentIp = '198.51.100.20' }) {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-update-'));
|
|
const cacheFile = path.join(tempDir, 'minigate_ddns_cfg01.cache');
|
|
const refreshLog = path.join(tempDir, 'refresh.log');
|
|
const uciLog = path.join(tempDir, 'uci.log');
|
|
const stateDir = path.join(tempDir, 'state');
|
|
if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`);
|
|
|
|
const command = `
|
|
set -u
|
|
export MINIGATE_DDNS_LIBRARY_ONLY=1
|
|
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
|
|
export MINIGATE_HISTORY_DIR=${JSON.stringify(path.join(tempDir, 'history'))}
|
|
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
|
|
export MINIGATE_DDNS_STATE_DIR=${JSON.stringify(stateDir)}
|
|
. ${JSON.stringify(ddnsScript)}
|
|
uci() {
|
|
[ "\${1:-}" = "-q" ] && shift
|
|
case "\${1:-}:\${2:-}" in
|
|
get:minigate.cfg01.enabled) printf '1\\n' ;;
|
|
get:minigate.cfg01.domain) printf '*.example.com\\n' ;;
|
|
get:minigate.cfg01.cf_zone_id) printf 'zone\\n' ;;
|
|
get:minigate.cfg01.cf_api_token) printf 'token\\n' ;;
|
|
get:minigate.cfg01.ip_source) printf 'url\\n' ;;
|
|
get:minigate.cfg01.interface) printf 'wan\\n' ;;
|
|
get:minigate.cfg01.ip_url) printf 'http://ip.example/\\n' ;;
|
|
get:minigate.cfg01.check_interval) printf '300\\n' ;;
|
|
get:minigate.cfg01.ip_version) printf 'ipv4\\n' ;;
|
|
set:*|commit:*) printf '%s\\n' "\$*" >> ${JSON.stringify(uciLog)} ;;
|
|
esac
|
|
return 0
|
|
}
|
|
get_ip4_url() { printf '%s\\n' ${JSON.stringify(currentIp)}; }
|
|
record_ip_history() { return 0; }
|
|
calc_next() { printf '12:00:00\\n'; }
|
|
cf_update() {
|
|
printf '%s\\n' ${JSON.stringify(cfResult)}
|
|
[ ${JSON.stringify(cfResult)} != false ]
|
|
}
|
|
refresh_local_dns_caches() {
|
|
printf '%s\\n' "\$1" >> ${JSON.stringify(refreshLog)}
|
|
return ${Number(refreshResult)}
|
|
}
|
|
update_one cfg01 1
|
|
`;
|
|
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
const refreshes = fs.existsSync(refreshLog) ? readRows(refreshLog) : [];
|
|
const uciWrites = fs.existsSync(uciLog) ? readRows(uciLog) : [];
|
|
const cache = fs.existsSync(cacheFile) ? fs.readFileSync(cacheFile, 'utf8').trim() : '';
|
|
const stateFile = path.join(stateDir, '__example_com.state');
|
|
const state = {};
|
|
if (fs.existsSync(stateFile)) {
|
|
for (const line of readRows(stateFile)) {
|
|
const at = line.indexOf('=');
|
|
state[line.slice(0, at)] = line.slice(at + 1);
|
|
}
|
|
}
|
|
return { result, refreshes, uciWrites, cache, state };
|
|
}
|
|
|
|
function runCloudflareScenario(mode) {
|
|
const command = `
|
|
set -u
|
|
export MINIGATE_DDNS_LIBRARY_ONLY=1
|
|
. ${JSON.stringify(ddnsScript)}
|
|
CF_MODE=${JSON.stringify(mode)}
|
|
curl() {
|
|
case "\$*" in
|
|
*'-X PUT'*|*'-X DELETE'*|*'-X POST'*) printf 'WRITE\\n' ;;
|
|
*'&content='*) printf 'MATCH\\n' ;;
|
|
*) printf 'LIST\\n' ;;
|
|
esac
|
|
}
|
|
jsonfilter() {
|
|
input=; IFS= read -r input || true
|
|
expression="\${2:-}"
|
|
case "\$expression:\$input:\$CF_MODE" in
|
|
'$.success:'*) [ "\$CF_MODE" = failure ] && printf 'false\\n' || printf 'true\\n' ;;
|
|
'$.result[*].id:LIST:create') ;;
|
|
'$.result[*].id:LIST:duplicates') printf 'r1\\nr2\\n' ;;
|
|
'$.result[*].id:LIST:'*) printf 'r1\\n' ;;
|
|
'$.result[0].id:MATCH:unchanged'|'$.result[0].id:MATCH:duplicates') printf 'r1\\n' ;;
|
|
esac
|
|
}
|
|
cf_update zone token example.com 198.51.100.20 A
|
|
`;
|
|
return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
}
|
|
|
|
test('DDNS history records only actual IPv4 changes', () => {
|
|
const { historyDir, output } = runHistory(`
|
|
export MINIGATE_HISTORY_NOW=100000
|
|
record_ip_history cfg01 ipv4 198.51.100.10
|
|
printf sentinel > "$MINIGATE_HISTORY_DIR/cfg01.ipv4.tsv.lock"
|
|
export MINIGATE_HISTORY_NOW=100060
|
|
record_ip_history cfg01 ipv4 198.51.100.10
|
|
cat "$MINIGATE_HISTORY_DIR/cfg01.ipv4.tsv.lock"
|
|
export MINIGATE_HISTORY_NOW=100120
|
|
record_ip_history cfg01 ipv4 198.51.100.11
|
|
`);
|
|
assert.equal(output, 'sentinel', 'unchanged IP must not reopen the history lock file');
|
|
assert.deepEqual(readRows(path.join(historyDir, 'cfg01.ipv4.tsv')), [
|
|
'100000\t198.51.100.10',
|
|
'100120\t198.51.100.11'
|
|
]);
|
|
assert.equal(fs.statSync(path.join(historyDir, 'cfg01.ipv4.tsv')).mode & 0o777, 0o600);
|
|
});
|
|
|
|
test('DDNS history keeps one anchor before the rolling window', () => {
|
|
const { historyDir } = runHistory(`
|
|
export MINIGATE_HISTORY_NOW=100
|
|
record_ip_history cfg02 ipv4 192.0.2.1
|
|
export MINIGATE_HISTORY_NOW=150
|
|
record_ip_history cfg02 ipv4 192.0.2.2
|
|
export MINIGATE_HISTORY_NOW=250
|
|
record_ip_history cfg02 ipv4 192.0.2.3
|
|
export MINIGATE_HISTORY_NOW=400
|
|
record_ip_history cfg02 ipv4 192.0.2.4
|
|
`, { window: 100 });
|
|
assert.deepEqual(readRows(path.join(historyDir, 'cfg02.ipv4.tsv')), [
|
|
'250\t192.0.2.3',
|
|
'400\t192.0.2.4'
|
|
]);
|
|
});
|
|
|
|
test('IPv4 and IPv6 histories stay separate and domain names are safe', () => {
|
|
const { historyDir } = runHistory(`
|
|
export MINIGATE_HISTORY_NOW=200000
|
|
record_ip_history '*.example.com' ipv4 203.0.113.7
|
|
record_ip_history '*.example.com' ipv6 2001:db8::7
|
|
`);
|
|
assert.deepEqual(readRows(path.join(historyDir, '__example_com.ipv4.tsv')), ['200000\t203.0.113.7']);
|
|
assert.deepEqual(readRows(path.join(historyDir, '__example_com.ipv6.tsv')), ['200000\t2001:db8::7']);
|
|
});
|
|
|
|
test('DDNS writer and controller both use the domain history key', () => {
|
|
const shellKey = fs.readFileSync(ddnsScript, 'utf8');
|
|
assert.match(shellKey, /record_ip_history "\$domain" "ipv4" "\$ip4"/);
|
|
assert.match(shellKey, /record_ip_history "\$domain" "ipv6" "\$ip6"/);
|
|
assert.match(controller, /history_section_name\(section\.domain\) \.\. "\." \.\. family \.\. "\.tsv"/);
|
|
assert.doesNotMatch(controller, /history_section_name\(section\.name\)/);
|
|
});
|
|
|
|
test('Cloudflare updater distinguishes unchanged records from authoritative changes', () => {
|
|
for (const mode of ['create', 'replace', 'duplicates']) {
|
|
const result = runCloudflareScenario(mode);
|
|
assert.equal(result.status, 0, `${mode}: ${result.stderr || result.stdout}`);
|
|
assert.equal(result.stdout.trim(), 'changed', mode);
|
|
}
|
|
const unchanged = runCloudflareScenario('unchanged');
|
|
assert.equal(unchanged.status, 0, unchanged.stderr || unchanged.stdout);
|
|
assert.equal(unchanged.stdout.trim(), 'unchanged');
|
|
|
|
const failure = runCloudflareScenario('failure');
|
|
assert.notEqual(failure.status, 0);
|
|
assert.equal(failure.stdout.trim(), 'false');
|
|
});
|
|
|
|
test('Cloudflare token stays out of curl arguments and every API call is time-bounded', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cf-token-'));
|
|
const calls = path.join(tempDir, 'calls.log');
|
|
const command = `
|
|
set -u
|
|
export MINIGATE_DDNS_LIBRARY_ONLY=1
|
|
. ${JSON.stringify(ddnsScript)}
|
|
curl() { printf 'args %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/stdin /' >> ${JSON.stringify(calls)}; printf '{}'; }
|
|
jsonfilter() { cat >/dev/null; [ "\${2:-}" = '$.success' ] && printf 'true\\n'; return 0; }
|
|
cf_update zone secret-token example.com 198.51.100.20 A
|
|
`;
|
|
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.equal(result.stdout.trim(), 'changed');
|
|
const lines = readRows(calls);
|
|
const args = lines.filter(line => line.startsWith('args '));
|
|
assert.equal(args.length, 2, 'list + create');
|
|
for (const line of args) {
|
|
assert.doesNotMatch(line, /secret-token/);
|
|
assert.match(line, /--max-time \d+/);
|
|
assert.match(line, /-H @-/);
|
|
}
|
|
assert.equal(lines.filter(line => line === 'stdin Authorization: Bearer secret-token').length, 2);
|
|
});
|
|
|
|
test('scheduled and manual DDNS runs share section names and serialize with a lock', () => {
|
|
const source = fs.readFileSync(ddnsScript, 'utf8');
|
|
assert.match(source, /uci -q -X show minigate \| grep '=ddns\$'/);
|
|
assert.match(source, /if \[ "\$1" = "single" \] && \[ -n "\$2" \]; then\n lock_ddns wait/);
|
|
assert.match(source, /elif ! lock_ddns; then/);
|
|
for (const line of source.split('\n').filter(item => /\bcurl -[46]/.test(item))) {
|
|
assert.match(line, /--max-time \d+/, line.trim());
|
|
}
|
|
});
|
|
|
|
function runScheduled({ force = false, checked }) {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-interval-'));
|
|
const calls = path.join(tempDir, 'calls.log');
|
|
for (const [sec, ts] of Object.entries(checked)) fs.writeFileSync(path.join(tempDir, `minigate_ddns_${sec}.checked`), `${ts}\n`);
|
|
const command = `
|
|
export MINIGATE_DDNS_LIBRARY_ONLY=1
|
|
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
|
|
export MINIGATE_DDNS_LOCK=${JSON.stringify(path.join(tempDir, 'ddns.lock'))}
|
|
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
|
|
. ${JSON.stringify(ddnsScript)}
|
|
date() { [ "\${1:-}" = '+%s' ] && printf '1000000\\n' || printf 'now\\n'; }
|
|
uci() {
|
|
[ "\${1:-}" = "-q" ] && shift
|
|
[ "\${1:-}" = "-X" ] && shift
|
|
case "\${1:-}:\${2:-}" in
|
|
get:minigate.global.enabled|get:minigate.cfg01.enabled|get:minigate.cfg02.enabled) printf '1\\n' ;;
|
|
get:minigate.cfg03.enabled) printf '0\\n' ;;
|
|
get:minigate.cfg01.check_interval) printf '300\\n' ;;
|
|
get:minigate.cfg02.check_interval) printf '900\\n' ;;
|
|
show:minigate) printf 'minigate.cfg01=ddns\\nminigate.cfg02=ddns\\nminigate.cfg03=ddns\\n' ;;
|
|
commit:minigate) printf 'commit\\n' >> ${JSON.stringify(calls)} ;;
|
|
esac
|
|
return 0
|
|
}
|
|
update_one() { printf '%s %s\\n' "\$1" "\$2" >> ${JSON.stringify(calls)}; }
|
|
main ${force ? 'force' : ''}
|
|
`;
|
|
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
assert.equal(result.stderr, '');
|
|
return {
|
|
calls: fs.existsSync(calls) ? readRows(calls) : [],
|
|
logged: fs.existsSync(path.join(tempDir, 'ddns.log'))
|
|
};
|
|
}
|
|
|
|
test('scheduled DDNS checks honour each record interval and skip disabled records', () => {
|
|
const due = runScheduled({ checked: { cfg01: 1000000 - 280, cfg02: 1000000 - 600 } });
|
|
assert.deepEqual(due.calls, ['cfg01 0'], 'scheduled runs never commit /etc/config');
|
|
|
|
const idle = runScheduled({ checked: { cfg01: 1000000 - 100, cfg02: 1000000 - 600 } });
|
|
assert.deepEqual(idle.calls, [], 'nothing due: no update and no config commit');
|
|
assert.equal(idle.logged, false, 'nothing due: no log noise every five minutes');
|
|
|
|
const forced = runScheduled({ force: true, checked: { cfg01: 1000000 - 10, cfg02: 1000000 - 10 } });
|
|
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1']);
|
|
});
|
|
|
|
test('DDNS interval is at least five minutes and the next run matches the cron grid', () => {
|
|
const { output } = runHistory(`
|
|
uci() { printf '%s\\n' "\${INTERVAL}"; }
|
|
date() { [ "\${1:-}" = '+%s' ] && printf '300150\\n' || printf '%s\\n' "\${2:-}"; }
|
|
INTERVAL=60; printf '%s ' "$(ddns_interval cfg01)"
|
|
INTERVAL=abc; printf '%s ' "$(ddns_interval cfg01)"
|
|
INTERVAL=600; printf '%s ' "$(ddns_interval cfg01)"
|
|
printf '%s %s ' "$(calc_next 300)" "$(calc_next 600)"
|
|
printf '%s\\n' 300300 > "\${MINIGATE_CACHE_DIR}/minigate_ddns_cfg09.checked"
|
|
INTERVAL=300; ddns_due cfg09 && printf 'clock-back-due'
|
|
`);
|
|
assert.equal(output, '300 300 600 @300600 @300900 clock-back-due');
|
|
});
|
|
|
|
test('successful Cloudflare changes refresh local DNS caches once', () => {
|
|
const scenario = runUpdateScenario({ cfResult: 'changed' });
|
|
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
|
assert.deepEqual(scenario.refreshes, ['*.example.com']);
|
|
assert.equal(scenario.cache, '198.51.100.20');
|
|
assert.equal(scenario.state.status, 'ok');
|
|
assert.equal(scenario.state.ip, '198.51.100.20');
|
|
assert.deepEqual(scenario.uciWrites, [], 'runtime state must not be written to /etc/config');
|
|
});
|
|
|
|
test('unchanged forced sync does not flush caches, but a stale local IP does', () => {
|
|
const current = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.20' });
|
|
assert.equal(current.result.status, 0, current.result.stderr || current.result.stdout);
|
|
assert.deepEqual(current.refreshes, []);
|
|
|
|
const stale = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.19' });
|
|
assert.equal(stale.result.status, 0, stale.result.stderr || stale.result.stdout);
|
|
assert.deepEqual(stale.refreshes, ['*.example.com']);
|
|
assert.equal(stale.cache, '198.51.100.20');
|
|
});
|
|
|
|
test('Cloudflare failure never flushes local caches', () => {
|
|
const scenario = runUpdateScenario({ cfResult: 'false', cachedIp: '198.51.100.19' });
|
|
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
|
assert.deepEqual(scenario.refreshes, []);
|
|
assert.equal(scenario.cache, '198.51.100.19');
|
|
assert.equal(scenario.state.status, 'error');
|
|
assert.deepEqual(scenario.uciWrites, []);
|
|
});
|
|
|
|
test('cache refresh failure does not turn a successful DDNS update into an error', () => {
|
|
const scenario = runUpdateScenario({ cfResult: 'changed', refreshResult: 1 });
|
|
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
|
assert.deepEqual(scenario.refreshes, ['*.example.com']);
|
|
assert.equal(scenario.state.status, 'ok');
|
|
});
|
|
|
|
test('a private interface address is never pushed to Cloudflare', () => {
|
|
for (const ip of ['192.168.2.2', '10.0.0.5', '100.64.1.1', '172.20.0.1']) {
|
|
const scenario = runUpdateScenario({ cfResult: 'changed', currentIp: ip });
|
|
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
|
assert.equal(scenario.state.status, 'error', ip);
|
|
assert.match(scenario.state.message, /不是公网地址/);
|
|
assert.equal(scenario.cache, '', 'cf_update must not run for ' + ip);
|
|
assert.deepEqual(scenario.refreshes, []);
|
|
}
|
|
});
|
|
|
|
test('public address checks reject private, carrier NAT, ULA and link-local ranges', () => {
|
|
const { output } = runHistory(`
|
|
for ip in 1.2.3.4 182.147.210.9 10.1.1.1 172.16.0.1 172.32.0.1 192.168.1.1 100.64.0.1 100.128.0.1 127.0.0.1 169.254.1.1 0.1.2.3 224.0.0.1 256.1.1.1 1.2.3; do
|
|
is_public_ipv4 "$ip" && printf '%s ' "$ip"
|
|
done
|
|
printf '| '
|
|
for ip in 2001:db8::1 240e:1::5 fd00::1 fe80::1 ::1 'x:y'; do
|
|
is_public_ipv6 "$ip" && printf '%s ' "$ip"
|
|
done
|
|
true
|
|
`);
|
|
assert.equal(output, '1.2.3.4 182.147.210.9 172.32.0.1 100.128.0.1 | 2001:db8::1 240e:1::5');
|
|
});
|
|
|
|
test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-dns-flush-'));
|
|
const config = path.join(tempDir, 'config.json');
|
|
const calls = path.join(tempDir, 'calls.log');
|
|
fs.writeFileSync(config, '{}\n');
|
|
const command = `
|
|
set -eu
|
|
export MINIGATE_DDNS_LIBRARY_ONLY=1
|
|
export MINIGATE_SINGBOX_CONFIG=${JSON.stringify(config)}
|
|
export MINIGATE_CLASH_API_URL=http://127.0.0.1:19095
|
|
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
|
|
. ${JSON.stringify(ddnsScript)}
|
|
jsonfilter() { printf 'local-secret\\n'; }
|
|
curl() { printf 'curl %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/header /' >> ${JSON.stringify(calls)}; printf '204'; }
|
|
pidof() { [ "\$1" = dnsmasq ] && printf '101 202\\n'; }
|
|
send_hup() { printf 'hup %s\\n' "\$*" >> ${JSON.stringify(calls)}; }
|
|
refresh_local_dns_caches '*.example.com'
|
|
`;
|
|
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
const callLog = fs.readFileSync(calls, 'utf8');
|
|
const curlLine = callLog.split('\n').find(line => line.startsWith('curl ')) || '';
|
|
assert.match(callLog, /curl .* -X POST -H @- http:\/\/127\.0\.0\.1:19095\/cache\/dns\/flush/);
|
|
assert.match(callLog, /header Authorization: Bearer local-secret/);
|
|
assert.doesNotMatch(curlLine, /local-secret/, 'secret must not appear in the curl process arguments');
|
|
assert.match(callLog, /hup 101 202/);
|
|
assert.doesNotMatch(callLog, /restart|reload|stop|start/);
|
|
});
|
|
|
|
test('controller exposes a read-only rolling 24-hour history endpoint', () => {
|
|
assert.match(controller, /entry\(\{"admin","services","minigate","ddns_history"\}, call\("action_ddns_history"\)\)/);
|
|
assert.match(controller, /local cutoff = now - 86400/);
|
|
assert.match(controller, /if end_time > cutoff and event\.timestamp <= now then/);
|
|
assert.match(controller, /duration=math\.max\(0, end_time - event\.timestamp\)/);
|
|
assert.match(controller, /active=\(next_event == nil\)/);
|
|
assert.doesNotMatch(controller, /ddns_history"\}, post\(/);
|
|
});
|
|
|
|
test('DDNS page includes responsive history controls and required columns', () => {
|
|
assert.match(model, /s\.template="minigate\/ddns_history"/);
|
|
for (const text of ['最近一天 IP 变化明细', 'IP 地址', '开始时间', '结束时间', '持续时间', '使用中']) {
|
|
assert.match(view, new RegExp(text));
|
|
}
|
|
assert.match(view, /@media\(max-width:640px\)/);
|
|
assert.match(view, /data-darkmode="true"/);
|
|
assert.match(view, /refresh\.disabled=true/);
|
|
assert.match(view, /textContent/);
|
|
});
|
|
|
|
test('DDNS page treats an empty JSON object as an empty array', () => {
|
|
const match = view.match(/function asArray\(value\)\{([\s\S]*?)\n \}/);
|
|
assert.ok(match, 'asArray helper must exist');
|
|
const asArray = Function('value', match[1]);
|
|
assert.deepEqual(asArray([]), []);
|
|
assert.deepEqual(asArray({}), []);
|
|
assert.equal(asArray({ unexpected: true }), null);
|
|
assert.equal(asArray(null), null);
|
|
assert.match(view, /nextRecords===null/);
|
|
assert.match(view, /records=nextRecords/);
|
|
});
|