💐 Sync 2026-10-06 12:33:44

This commit is contained in:
github-actions[bot] committed 2026-10-06 12:33:44 +08:00
1 parent 8be03ce939
commit 7fe69dbb3a
246 files changed
+50330 -3414

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
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_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);
}
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('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/);
});
+151
View File
@@ -0,0 +1,151 @@
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 installerPath = path.join(root, 'scripts/install.sh');
const source = fs.readFileSync(installerPath, 'utf8');
const sourceInstaller = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const sourceDependencyFunctions = sourceInstaller.slice(
sourceInstaller.indexOf('dependency_ready()'),
sourceInstaller.indexOf('\n# 检测包管理器')
);
const functions = source.slice(source.indexOf('info()'), source.lastIndexOf('\nmain "$@"'));
const program = source.slice(0, source.lastIndexOf('\nmain "$@"'));
const currentVersion = fs.readFileSync(path.join(root, 'Makefile'), 'utf8').match(/^PKG_VERSION:=(.+)$/m)[1];
function shell(body, env = {}) {
return spawnSync('/bin/sh', ['-c', body], {
encoding: 'utf8',
env: { ...process.env, ...env }
});
}
test('online installer is valid POSIX shell and uses the fixed repository', () => {
const result = spawnSync('/bin/sh', ['-n', installerPath], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.match(source, /^REPOSITORY="tpxcer\/minigate"$/m);
assert.match(source, /releases\/latest\/download/);
});
test('source installer skips package installation when a working OpenSSL CLI already exists', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-'));
const calls = path.join(tempDir, 'apk-calls');
const openssl = path.join(tempDir, 'openssl');
const apk = path.join(tempDir, 'apk');
fs.writeFileSync(openssl, '#!/bin/sh\n[ "$1" = version ]\n', { mode: 0o755 });
fs.writeFileSync(apk, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\n[ "$1" = info ] && [ "$3" != openssl-util ]\n`, { mode: 0o755 });
const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir });
assert.equal(result.status, 0, result.stderr);
const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
assert.doesNotMatch(log, /openssl-util/);
});
test('source installer installs openssl-util when OpenSSL CLI is unavailable', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-'));
const calls = path.join(tempDir, 'apk-calls');
const apk = path.join(tempDir, 'apk');
fs.writeFileSync(apk, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\nif [ "$1" = info ]; then [ "$3" != openssl-util ]; else exit 0; fi\n`, { mode: 0o755 });
const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir });
assert.equal(result.status, 0, result.stderr);
assert.match(fs.readFileSync(calls, 'utf8'), /add openssl-util/);
});
test('release manifest selects one valid minigate source asset', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sumsPath = path.join(tempDir, 'SHA256SUMS');
const hash = 'a'.repeat(64);
fs.writeFileSync(sumsPath, [
`${'b'.repeat(64)} minigate_2026.9.14-3_all.ipk`,
`${hash} minigate-v2026.9.14-3-src.tar.gz`,
`${hash} luci-app-minigate-v2026.9.14-3-src.tar.gz`
].join('\n'));
const result = shell(`${functions}\nread_release "$SUMS" && printf '%s|%s|%s' "$RELEASE_VERSION" "$SOURCE_NAME" "$EXPECTED_HASH"`, { SUMS: sumsPath });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `2026.9.14-3|minigate-v2026.9.14-3-src.tar.gz|${hash}`);
});
test('release manifest rejects duplicate or malformed source entries', () => {
for (const entries of [
[`${'a'.repeat(64)} minigate-v2026.9.14-3-src.tar.gz`, `${'b'.repeat(64)} minigate-v2026.9.14-4-src.tar.gz`],
[`${'a'.repeat(64)} minigate-v2026.9.14-0-src.tar.gz`],
[`not-a-hash minigate-v2026.9.14-3-src.tar.gz`]
]) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sumsPath = path.join(tempDir, 'SHA256SUMS');
fs.writeFileSync(sumsPath, entries.join('\n'));
const result = shell(`${functions}\nread_release "$SUMS"`, { SUMS: sumsPath });
assert.notEqual(result.status, 0, entries.join(','));
}
});
test('source metadata must match the selected release version', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'root/usr/lib/minigate'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'Makefile'), 'PKG_VERSION:=2026.9.14-3\n');
fs.writeFileSync(path.join(tempDir, 'scripts/build-ipk.sh'), 'PKG_VERSION="2026.9.14-3"\n');
fs.writeFileSync(path.join(tempDir, 'root/usr/lib/minigate/update.sh'), 'CURRENT_VERSION="2026.9.14-3"\n');
fs.writeFileSync(path.join(tempDir, 'install.sh'), '#!/bin/sh\n');
const valid = shell(`${functions}\nRELEASE_VERSION=2026.9.14-3; verify_metadata "$SOURCE_DIR"`, { SOURCE_DIR: tempDir });
assert.equal(valid.status, 0, valid.stderr);
const invalid = shell(`${functions}\nRELEASE_VERSION=2026.9.14-4; verify_metadata "$SOURCE_DIR"`, { SOURCE_DIR: tempDir });
assert.notEqual(invalid.status, 0);
});
test('online installer verifies and installs a mocked latest release end to end', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sourceDir = path.join(tempDir, 'source');
const archivePath = path.join(tempDir, `minigate-v${currentVersion}-src.tar.gz`);
const sumsPath = path.join(tempDir, 'SHA256SUMS');
const markerPath = path.join(tempDir, 'installed');
fs.mkdirSync(path.join(sourceDir, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(sourceDir, 'root/usr/lib/minigate'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'Makefile'), `PKG_VERSION:=${currentVersion}\n`);
fs.writeFileSync(path.join(sourceDir, 'scripts/build-ipk.sh'), `PKG_VERSION="${currentVersion}"\n`);
fs.writeFileSync(path.join(sourceDir, 'root/usr/lib/minigate/update.sh'), `CURRENT_VERSION="${currentVersion}"\n`);
fs.writeFileSync(path.join(sourceDir, 'install.sh'), '#!/bin/sh\nprintf installed > "$INSTALL_MARKER"\n');
const tar = spawnSync('tar', ['-czf', archivePath, '-C', sourceDir, '.'], { encoding: 'utf8' });
assert.equal(tar.status, 0, tar.stderr);
const digest = spawnSync('shasum', ['-a', '256', archivePath], { encoding: 'utf8' }).stdout.split(/\s+/)[0];
fs.writeFileSync(sumsPath, `${digest} ${path.basename(archivePath)}\n`);
const result = shell(`${program}
check_system() { :; }
detect_downloader() { DOWNLOADER=test; }
fetch_to_file() {
case "$1" in
*/SHA256SUMS) cp "$TEST_SUMS" "$2" ;;
*/minigate-v*-src.tar.gz) cp "$TEST_ARCHIVE" "$2" ;;
*) return 1 ;;
esac
}
restart_luci() { printf restarted >> "$INSTALL_MARKER"; }
main
`, { TEST_SUMS: sumsPath, TEST_ARCHIVE: archivePath, INSTALL_MARKER: markerPath });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(markerPath, 'utf8'), 'installedrestarted');
assert.match(result.stdout, new RegExp(`minigate v${currentVersion.replaceAll('.', '\\.')} 安装完成`));
});
test('online installer stops before installation when SHA-256 does not match', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const archivePath = path.join(tempDir, 'source.tar.gz');
fs.writeFileSync(archivePath, 'not an archive');
const result = shell(`${functions}\nEXPECTED_HASH=${'0'.repeat(64)}; verify_source "$ARCHIVE"`, { ARCHIVE: archivePath });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SHA-256/);
});
test('README exposes the short command and keeps a fixed-version fallback', () => {
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
assert.match(readme, /curl -fsSL https:\/\/raw\.githubusercontent\.com\/tpxcer\/minigate\/main\/scripts\/install\.sh \| sh/);
assert.ok(readme.includes(`releases/download/v${currentVersion}/minigate-v${currentVersion}-src.tar.gz`));
});
+114
View File
@@ -0,0 +1,114 @@
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 script = path.join(root, 'root/usr/lib/minigate/uninstall.sh');
function write(base, relative, content = relative) {
const target = path.join(base, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
return target;
}
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-uninstall-test-'));
write(dir, 'etc/config/minigate', 'config-data');
write(dir, 'etc/minigate/certs/site.pem', 'certificate-data');
write(dir, 'etc/minigate/login-guard/bans.txt', '198.51.100.4 9999999999');
write(dir, 'etc/crontabs/root', '0 1 * * * /bin/other\n*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh\n');
write(dir, 'etc/init.d/minigate');
write(dir, 'usr/lib/minigate/update.sh');
write(dir, 'usr/lib/minigate/uninstall.sh');
write(dir, 'usr/lib/lua/luci/controller/minigate.lua');
write(dir, 'usr/lib/lua/luci/model/cbi/minigate/general.lua');
write(dir, 'usr/lib/lua/luci/view/minigate/log.htm');
write(dir, 'usr/lib/lua/luci/i18n/minigate.zh-cn.lmo');
write(dir, 'usr/lib/lua/luci/controller/keep.lua');
write(dir, 'var/log/minigate-access.log', 'access-log');
write(dir, 'var/run/minigate/socket');
write(dir, 'var/run/minigate-nginx.pid', '123');
write(dir, 'tmp/minigate-update-state.json', '{}');
write(dir, 'tmp/minigate_proxy_443.tmp');
write(dir, 'tmp/luci-indexcache');
return dir;
}
function run(dir, purge) {
return spawnSync('/bin/sh', [script, purge], {
encoding: 'utf8',
env: {
...process.env,
MINIGATE_ROOT: dir,
MINIGATE_SKIP_PACKAGES: '1',
MINIGATE_SKIP_SERVICES: '1'
}
});
}
test('default uninstall removes the app but preserves configuration, certificates, bans and logs', () => {
const dir = fixture();
const result = run(dir, '0');
assert.equal(result.status, 0, result.stderr);
for (const relative of [
'etc/init.d/minigate',
'usr/lib/minigate',
'usr/lib/lua/luci/controller/minigate.lua',
'usr/lib/lua/luci/model/cbi/minigate',
'usr/lib/lua/luci/view/minigate',
'usr/lib/lua/luci/i18n/minigate.zh-cn.lmo',
'var/run/minigate',
'var/run/minigate-nginx.pid',
'tmp/minigate-update-state.json',
'tmp/minigate_proxy_443.tmp',
'tmp/luci-indexcache'
]) assert.equal(fs.existsSync(path.join(dir, relative)), false, relative);
assert.equal(fs.readFileSync(path.join(dir, 'etc/config/minigate'), 'utf8'), 'config-data');
assert.equal(fs.readFileSync(path.join(dir, 'etc/minigate/certs/site.pem'), 'utf8'), 'certificate-data');
assert.equal(fs.readFileSync(path.join(dir, 'etc/minigate/login-guard/bans.txt'), 'utf8'), '198.51.100.4 9999999999');
assert.equal(fs.readFileSync(path.join(dir, 'var/log/minigate-access.log'), 'utf8'), 'access-log');
assert.equal(fs.readFileSync(path.join(dir, 'etc/crontabs/root'), 'utf8'), '0 1 * * * /bin/other\n');
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/keep.lua')), true);
});
test('purge uninstall also removes configuration, certificates, bans and logs', () => {
const dir = fixture();
const result = run(dir, '1');
assert.equal(result.status, 0, result.stderr);
for (const relative of ['etc/config/minigate', 'etc/minigate', 'var/log/minigate-access.log', 'usr/lib/minigate']) {
assert.equal(fs.existsSync(path.join(dir, relative)), false, relative);
}
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/keep.lua')), true);
});
test('invalid purge mode is rejected before files are changed', () => {
const dir = fixture();
const result = run(dir, 'yes');
assert.equal(result.status, 2);
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/minigate/update.sh')), true);
assert.equal(fs.existsSync(path.join(dir, 'etc/config/minigate')), true);
});
test('LuCI exposes only a confirmed POST uninstall action and runs a temporary script copy', () => {
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
assert.match(controller, /\{"admin","services","minigate","uninstall"\}, post\("action_uninstall"\)/);
assert.match(controller, /confirm ~= "uninstall-minigate"/);
assert.match(controller, /purge ~= "0" and purge ~= "1"/);
assert.match(controller, /mktemp \/tmp\/minigate-uninstall\.XXXXXX/);
assert.match(controller, /sleep 1; \/bin\/sh/);
});
test('the package contains the uninstaller and supports both OpenWrt package managers', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const source = fs.readFileSync(script, 'utf8');
assert.match(makefile, /root\/usr\/lib\/minigate\/uninstall\.sh/);
assert.match(source, /opkg remove "\$package"/);
assert.match(source, /apk del "\$package"/);
assert.match(source, /for package in minigate luci-app-minigate/);
});
+292
View File
@@ -0,0 +1,292 @@
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 vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'root/usr/lib/minigate/update.sh'), 'utf8');
const functions = source.slice(source.indexOf('json_escape()'), source.lastIndexOf('\ncase "${1:-status}"'));
const version = source.match(/^CURRENT_VERSION="([^"]+)"/m)[1];
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
function shell(body, env = {}) {
const result = spawnSync('/bin/sh', ['-c', body], { encoding: 'utf8', env: { ...process.env, ...env } });
assert.equal(result.error, undefined);
return result;
}
function backend(body, options = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-test-'));
const fixture = { tag_name: `v${options.latest || version}`, name: 'minigate', body: 'Update notes' };
fs.writeFileSync(path.join(dir, 'fixture.json'), JSON.stringify(fixture));
fs.writeFileSync(path.join(dir, 'jsonfilter.cjs'), `
const fs = require('node:fs');
const args = process.argv.slice(2);
try {
const value = JSON.parse(fs.readFileSync(args[args.indexOf('-i')+1], 'utf8'));
const result = value[args[args.indexOf('-e')+1].slice(2)];
if (result !== undefined) console.log(result);
} catch (_) { process.exitCode = 1; }
`);
const result = shell(`
CURRENT_VERSION=${quote(version)}
STATE_CURRENT="$CURRENT_VERSION"
STATE_FILE="$TEST_DIR/state.json"
RELEASE_FILE="$TEST_DIR/release.json"
LAST_CHECK_FILE="$TEST_DIR/last-check"
LOCK_FILE="$TEST_DIR/lock"
TMP_ROOT="$TEST_DIR/work"
RUN_LOG="$TEST_DIR/log"
API_URL=https://example.invalid/latest
${functions}
jsonfilter() { "$NODE" "$TEST_DIR/jsonfilter.cjs" "$@"; }
flock() {
printf '%s\n' "$*" >> "$TEST_DIR/flocks"
[ "$1" = "-u" ] && return 0
test "\${FLOCK_BUSY:-0}" = 0
}
download_file() {
printf 'fetch\\n' >> "$TEST_DIR/fetches"
test "\${FETCH_FAIL:-0}" = 0 || return 1
cp "$TEST_DIR/fixture.json" "$2"
}
backup_program() { echo 'UNEXPECTED INSTALL' >&2; exit 99; }
${body}
`, { TEST_DIR: dir, NODE: process.execPath, ...options.env });
return {
...result,
state: fs.existsSync(path.join(dir, 'state.json')) ? JSON.parse(fs.readFileSync(path.join(dir, 'state.json'))) : null,
fetches: fs.existsSync(path.join(dir, 'fetches')) ? fs.readFileSync(path.join(dir, 'fetches'), 'utf8').trim().split('\n').length : 0,
flocks: fs.existsSync(path.join(dir, 'flocks')) ? fs.readFileSync(path.join(dir, 'flocks'), 'utf8').trim().split('\n') : []
};
}
test('date versions compare numeric day and same-day sequence, including legacy version', () => {
for (const [left, right, newer] of [
['2026.9.14', '1.3.11', true], ['2026.9.14', '2026.9.14', false],
['2026.9.14-1', '2026.9.14', true], ['2026.9.14-10', '2026.9.14-2', true],
['2026.9.14-2', '2026.9.14-10', false], ['2026.9.15', '2026.9.14-99', true],
['2026.10.1', '2026.9.30-9', true], ['2027.1.1', '2026.12.31', true],
['1.3.11', '2026.9.14', false]
]) {
assert.equal(shell(`${functions}\nversion_newer ${quote(left)} ${quote(right)}`).status, newer ? 0 : 1, `${left} > ${right}`);
}
});
test('reject malformed versions and shell syntax in tags', () => {
for (const value of ['2026.9.14', '2026.9.14-1', '2026.9.14-10']) {
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 0, value);
}
for (const value of ['', 'latest', '1.3.11', '2026.13.14', '2026.9.0', '2026.9.14-0', '2026.9.14-01', '2026.9.14;id', '../2026.9.14']) {
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 1, value);
}
});
test('opening overview checks once then reuses a successful ten-minute cache', () => {
const result = backend('run_auto_check >/dev/null; run_auto_check');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.fetches, 1);
assert.equal(result.state.status, 'up_to_date');
});
test('manual check, expired cache and changed installed version force a fresh check', () => {
for (const next of ['run_check', 'echo 1 > "$LAST_CHECK_FILE"; run_auto_check', 'CURRENT_VERSION=2020.1.1; STATE_CURRENT=$CURRENT_VERSION; run_auto_check']) {
const result = backend(`run_check >/dev/null; ${next}`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.fetches, 2);
}
});
test('success/error/running state is not reused as a successful check cache', () => {
for (const state of ['success', 'error', 'installing']) {
const result = backend(`run_check >/dev/null; write_state ${state} 100 "$CURRENT_VERSION" false false true done; run_auto_check`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.state.status, 'up_to_date');
assert.equal(result.fetches, 2);
}
});
test('busy updater is reported without downloading or starting another update', () => {
const result = backend('write_state installing 75 "2027.1.1" true true true busy; FLOCK_BUSY=1; run_auto_check');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.state.status, 'installing');
assert.equal(result.fetches, 0);
});
test('apply releases its lock even when the locked operation returns early', () => {
const result = backend('run_apply_locked() { return 7; }; run_apply "$CURRENT_VERSION"');
assert.equal(result.status, 7, result.stderr);
assert.deepEqual(result.flocks, ['-n 9', '-u 9']);
});
test('long-running services are started with the update lock descriptor closed', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
assert.match(installer, /\/etc\/init\.d\/minigate start 9>&-/);
assert.match(source, /\/etc\/init\.d\/minigate start >> "\$RUN_LOG" 2>&1 9>&-/);
assert.equal((source.match(/\/etc\/init\.d\/uhttpd restart >> "\$RUN_LOG" 2>&1 9>&-/g) || []).length, 2);
});
test('network failure is recoverable and never starts installation', () => {
const result = backend('run_auto_check', { env: { FETCH_FAIL: '1' } });
assert.equal(result.status, 1);
assert.equal(result.state.status, 'error');
assert.equal(result.state.running, false);
});
test('install requires an explicitly confirmed version and rejects a changed release', () => {
const missing = backend('run_apply ""');
assert.equal(missing.status, 1);
assert.equal(missing.fetches, 0);
const changed = backend('run_apply "2027.1.1"', { latest: '2027.1.2' });
assert.equal(changed.status, 1);
assert.equal(changed.state.latest, '2027.1.2');
assert.equal(changed.state.available, true);
assert.equal(changed.state.running, false);
assert.match(changed.state.message, /重新/);
});
function overview() {
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
const js = lua.slice(lua.indexOf('var _updatePolling'), lua.indexOf('var limitSel')).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, { style: {}, className: '', textContent: '', disabled: false, checked: false, hidden: false, setAttribute() {}, focus() { document.activeElement = this; } });
return elements.get(id);
};
const listeners = {};
const document = {
body: { style: { overflow: 'auto' } }, activeElement: null,
getElementById: element, querySelector: () => null, addEventListener: (name, handler) => { listeners[name] = handler; }
};
const buttons = ['close', 'mg-update-modal-notes', 'cancel', 'mg-update-confirm'].map(element);
const uninstallItems = ['mg-uninstall-close', 'mg-uninstall-purge', 'mg-uninstall-cancel', 'mg-uninstall-confirm'].map(element);
element('mg-update-modal').querySelectorAll = () => buttons;
element('mg-update-modal').contains = item => buttons.includes(item);
element('mg-uninstall-modal').querySelectorAll = () => uninstallItems;
element('mg-uninstall-modal').contains = item => uninstallItems.includes(item);
const requests = [], timers = [];
const context = vm.createContext({ document, mgColor: value => value, location: { reload() {} }, setTimeout: callback => timers.push(callback), XHR: {
get: (url, data, callback) => requests.push({ method: 'GET', url, data, callback }),
post: (url, data, callback) => requests.push({ method: 'POST', url, data, callback })
} });
vm.runInContext(js, context);
const available = { status: 'available', current: version, latest: '2027.1.1', available: true, success: true, release_title: 'New release', release_notes: '<script>bad()</script>\n中文说明' };
return { context, document, element, requests, timers, available, buttons, uninstallItems, listeners };
}
test('overview automatically checks but does not install', () => {
const page = overview();
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
assert.match(lua, /mgAutoCheckUpdate\(\);\s*<\/script>/);
page.context.mgAutoCheckUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'GET');
assert.equal(page.requests[0].url, 'xu');
page.requests[0].callback(null, page.available);
page.context.mgApplyUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
page.context.mgCloseUpdateDialog();
page.context.mgStartUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.document.body.style.overflow, 'auto');
});
test('confirm installs exactly the shown version, ignores double clicks and preserves retry notes', () => {
const page = overview();
page.context.mgRenderUpdate(page.available);
page.context.mgApplyUpdate();
page.context.mgRenderUpdate({ ...page.available, latest: '2027.1.2' });
page.context.mgStartUpdate();
page.context.mgStartUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'POST');
assert.equal(page.requests[0].data.version, '2027.1.1');
page.requests[0].callback(null, { success: false, message: 'Retry' });
page.context.mgApplyUpdate();
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
});
test('dialog traps keyboard focus and Escape cancels without installation', () => {
const page = overview();
page.context.mgRenderUpdate(page.available);
page.context.mgApplyUpdate();
page.buttons.at(-1).focus();
page.listeners.keydown({ key: 'Tab', preventDefault() {} });
assert.equal(page.document.activeElement, page.buttons[0]);
page.listeners.keydown({ key: 'Tab', shiftKey: true, preventDefault() {} });
assert.equal(page.document.activeElement, page.buttons.at(-1));
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
assert.equal(page.element('mg-update-modal').className, 'mg-modal');
assert.equal(page.requests.length, 0);
});
test('uninstall defaults to preserving data and cancel, Escape and backdrop never submit', () => {
const page = overview();
page.element('mg-uninstall-purge').checked = true;
page.context.mgOpenUninstallDialog();
assert.equal(page.element('mg-uninstall-purge').checked, false);
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
assert.match(page.element('mg-uninstall-detail').textContent, /默认保留/);
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
assert.equal(page.requests.length, 0);
page.context.mgOpenUninstallDialog();
page.element('mg-uninstall-modal').onclick({ target: page.element('mg-uninstall-modal') });
assert.equal(page.requests.length, 0);
page.context.mgOpenUninstallDialog();
page.context.mgCloseUninstallDialog();
assert.equal(page.requests.length, 0);
});
test('uninstall submits an explicit purge choice once and redirects only after acceptance', () => {
const page = overview();
page.context.mgOpenUninstallDialog();
page.element('mg-uninstall-purge').checked = true;
page.context.mgUpdateUninstallChoice();
assert.equal(page.element('mg-uninstall-confirm').textContent, '彻底卸载');
page.context.mgStartUninstall();
page.context.mgStartUninstall();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'POST');
assert.equal(page.requests[0].url, 'du');
assert.equal(page.requests[0].data.confirm, 'uninstall-minigate');
assert.equal(page.requests[0].data.purge, '1');
page.requests[0].callback(null, { success: true });
assert.match(page.element('mg-uninstall-status').textContent, /返回服务页面/);
assert.equal(page.timers.length, 1);
page.timers[0]();
assert.equal(page.context.location.href, 'services_url');
});
test('a rejected uninstall remains open and can be retried', () => {
const page = overview();
page.context.mgOpenUninstallDialog();
page.context.mgStartUninstall();
page.requests[0].callback(null, { success: false, message: 'busy' });
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
assert.equal(page.element('mg-uninstall-confirm').disabled, false);
assert.equal(page.element('mg-uninstall-status').textContent, 'busy');
page.context.mgStartUninstall();
assert.equal(page.requests.length, 2);
});
test('an old success state never reloads an overview that did not start the update', () => {
const page = overview();
page.context.mgRenderUpdate({ ...page.available, status: 'success', available: false });
assert.equal(page.timers.length, 0);
});
test('build metadata has a consistent version and the actual minigate package identity', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const build = fs.readFileSync(path.join(root, 'scripts/build-ipk.sh'), 'utf8');
assert.equal(makefile.match(/^PKG_VERSION:=(.+)$/m)[1], version);
assert.equal(build.match(/^PKG_VERSION="(.+)"$/m)[1], version);
assert.match(makefile, /^PKG_NAME:=minigate$/m);
assert.match(build, /^PKG_NAME="minigate"$/m);
assert.equal((build.match(/^Version:/gm) || []).length, 1);
assert.ok(fs.existsSync(path.join(root, `releases/v${version}.md`)));
});