mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 07:55:13 +08:00
💐 Sync 2026-10-06 12:33:44
This commit is contained in:
1 parent
8be03ce939
commit
7fe69dbb3a
246 files changed
+50330
-3414
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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`)));
|
||||
});
|
||||
Reference in new issue
Block a user