mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
211 lines
10 KiB
JavaScript
211 lines
10 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 lua = process.env.MINIGATE_TEST_LUA;
|
|
const root = path.resolve(__dirname, '..');
|
|
const controller = path.join(root, 'luasrc/controller/minigate.lua');
|
|
|
|
function runLua(source) {
|
|
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: source });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function luaFiles(dir) {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
const full = path.join(dir, entry.name);
|
|
return entry.isDirectory() ? luaFiles(full) : (entry.name.endsWith('.lua') ? [full] : []);
|
|
});
|
|
}
|
|
|
|
test('every LuCI Lua file compiles', { skip: !lua }, () => {
|
|
const files = luaFiles(path.join(root, 'luasrc'));
|
|
assert.ok(files.length >= 6);
|
|
const checks = files.map(file => `assert(loadfile(${JSON.stringify(file)}))`).join('\n');
|
|
runLua(checks + '\nprint("ok")');
|
|
});
|
|
|
|
// 加载控制器时只需要 luci.http;其他模块在函数内部按需 require
|
|
const controllerPrelude = `
|
|
local response, status_code, calls, form = nil, nil, {}, {}
|
|
luci = {http={
|
|
formvalue=function(name) return form[name] end,
|
|
prepare_content=function() end,
|
|
write_json=function(v) response=v end,
|
|
status=function(code) status_code=code end
|
|
}}
|
|
package.preload['luci.sys'] = function() return {
|
|
call=function(cmd) calls[#calls + 1] = cmd; return 0 end,
|
|
exec=function() return '' end
|
|
} end
|
|
package.preload['luci.model.uci'] = function() return {cursor=function() return {
|
|
get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end,
|
|
foreach=function() end
|
|
} end} end
|
|
dofile(${JSON.stringify(controller)})
|
|
local c = luci.controller.minigate
|
|
`;
|
|
|
|
test('certificate expiry parsing is timezone independent', { skip: !lua }, () => {
|
|
const expected = Date.UTC(2026, 11, 16, 12, 0, 0) / 1000;
|
|
const out = runLua(controllerPrelude + `
|
|
print(c.parse_cert_time('Dec 16 12:00:00 2026 GMT'))
|
|
print(c.parse_cert_time('Feb 1 00:00:00 2028 GMT'))
|
|
print(tostring(c.parse_cert_time('garbage')))
|
|
`);
|
|
assert.deepEqual(out.split('\n'), [String(expected), String(Date.UTC(2028, 1, 1) / 1000), 'nil']);
|
|
});
|
|
|
|
test('log view merges sources by time and keeps untimed lines with their entry', { skip: !lua }, () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-logs-'));
|
|
fs.writeFileSync(path.join(dir, 'ddns.log'), '2026-10-28 10:00:01 [DDNS] a\n2026-10-28 10:00:05 [DDNS] c\n');
|
|
fs.writeFileSync(path.join(dir, 'nginx.log'), '2026/10/28 10:00:03 [emerg] b\n');
|
|
fs.writeFileSync(path.join(dir, 'update.log'), '2026-10-28 10:00:04 start\ncurl output without time\n');
|
|
const out = runLua(controllerPrelude + `
|
|
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}, {'Nginx', ${JSON.stringify(path.join(dir, 'nginx.log'))}},
|
|
{'更新', ${JSON.stringify(path.join(dir, 'update.log'))}}, {'缺失', '/missing.log'}}, 300))
|
|
print('--')
|
|
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}}, 1))
|
|
`);
|
|
assert.equal(out, [
|
|
'[DDNS] 2026-10-28 10:00:01 [DDNS] a',
|
|
'[Nginx] 2026/10/28 10:00:03 [emerg] b',
|
|
'[更新] 2026-10-28 10:00:04 start',
|
|
'[更新] curl output without time',
|
|
'[DDNS] 2026-10-28 10:00:05 [DDNS] c',
|
|
'--',
|
|
'[DDNS] 2026-10-28 10:00:05 [DDNS] c'
|
|
].join('\n'));
|
|
});
|
|
|
|
test('state files are read by the same domain key the shell scripts write', { skip: !lua }, () => {
|
|
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-state-')), 'proxy.status');
|
|
fs.writeFileSync(file, 'result=ok\nmessage=配置检查通过\nwarning=one\nwarning=two=2\n');
|
|
const out = runLua(controllerPrelude + `
|
|
local st = c.read_state(${JSON.stringify(file)})
|
|
print(st.result, st.message, #st.warnings, st.warnings[2])
|
|
print(c.state_key('*.example.com'), c.state_key('home.example.com'))
|
|
print(tostring(c.read_state('/missing/state')))
|
|
`);
|
|
assert.equal(out, 'ok\t配置检查通过\t2\ttwo=2\n__example_com\thome_example_com\nnil');
|
|
});
|
|
|
|
test('login guard endpoints accept IPv6, quote the address and reject shell text', { skip: !lua }, () => {
|
|
const out = runLua(controllerPrelude + `
|
|
for _, ip in ipairs({'2001:db8::1', '198.51.100.7', "1.2.3.4'; reboot; '", 'abc'}) do
|
|
form.ip = ip; calls = {}; response = nil
|
|
c.action_lg_ban()
|
|
print(response.success and calls[1] or 'rejected')
|
|
end
|
|
`);
|
|
assert.equal(out, [
|
|
"/bin/sh /usr/lib/minigate/login_guard.sh ban '2001:db8::1' >/dev/null 2>&1",
|
|
"/bin/sh /usr/lib/minigate/login_guard.sh ban '198.51.100.7' >/dev/null 2>&1",
|
|
'rejected',
|
|
'rejected'
|
|
].join('\n'));
|
|
});
|
|
|
|
test('manual DDNS sync starts a background job only for a real DDNS section', { skip: !lua }, () => {
|
|
const out = runLua(controllerPrelude + `
|
|
form.section = 'cfg01'; c.action_ddns_sync()
|
|
print(response.success, calls[1])
|
|
form.section = 'cfg01;reboot'; calls = {}; c.action_ddns_sync()
|
|
print(response.success, status_code, #calls)
|
|
`);
|
|
const [started, rejected] = out.split('\n');
|
|
assert.match(started, /^true\t\( \/bin\/sh \/usr\/lib\/minigate\/job\.sh ddns-cfg01 \/bin\/sh \/usr\/lib\/minigate\/ddns\.sh single 'cfg01' \) <\/dev\/null >\/dev\/null 2>&1 &$/);
|
|
assert.equal(rejected, 'false\t400\t0');
|
|
});
|
|
|
|
// 用最小 CBI 桩加载每个页面,并执行表单渲染时会调用的 cfgvalue
|
|
const cbiPrelude = `
|
|
local options = {}
|
|
local function node()
|
|
local n = {}
|
|
function n:option(kind, name, title)
|
|
local o = {kind=kind, name=name, choices={}}
|
|
function o:value(k, v) self.choices[k] = v or k end
|
|
function o:depends() end
|
|
options[name] = o
|
|
return o
|
|
end
|
|
function n:tab() end
|
|
function n:taboption(_, ...) return n:option(...) end
|
|
return n
|
|
end
|
|
function Map(config, title)
|
|
local m = node()
|
|
m.title = title
|
|
m.uci = {get=function(_, _, sec, opt) local v; if opt == 'domain' then v = 'home.example.com' end; return v end}
|
|
function m:section() return node() end
|
|
return m
|
|
end
|
|
for _, name in ipairs({'Flag','Value','DynamicList','ListValue','DummyValue','MultiValue','NamedSection','TypedSection','SimpleSection'}) do _G[name] = name end
|
|
luci = {dispatcher={build_url=function(...) return '/cgi-bin/luci/' .. table.concat({...}, '/') end}}
|
|
package.preload['luci.sys'] = function() return {exec=function() return '' end, call=function() return 0 end} end
|
|
package.preload['nixio.fs'] = function() return {access=function() return false end, dir=function() return nil end, stat=function() return nil end} end
|
|
package.preload['luci.model.uci'] = function() return {cursor=function() return {
|
|
foreach=function(_, config, kind, fn) if kind == 'ddns' then fn({domain='home.example.com'}); fn({domain='*.example.com'}) end end,
|
|
get=function(_, _, _, opt) local v; if opt == 'staging' then v = '1' end; return v end
|
|
} end} end
|
|
package.preload['luci.controller.minigate'] = function() return {
|
|
ddns_state=function() return {status='error', message='IPv4 192.168.2.2 不是公网地址', ip='198.51.100.1'} end,
|
|
cert_list=function() return {{domain='*.example.com', expires_date='2026-11-02', days_left=5, staging=true}} end
|
|
} end
|
|
package.preload['luci.cbi.datatypes'] = function() return {
|
|
ip4addr=function(v) return v:match('^%d+%.%d+%.%d+%.%d+$') ~= nil end,
|
|
ip6addr=function(v) return v:match('^[%x:]+$') ~= nil and v:find(':') ~= nil end
|
|
} end
|
|
local function page(name) return assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate'))} .. '/' .. name .. '.lua')) end
|
|
`;
|
|
|
|
test('every LuCI page loads and renders its status blocks', { skip: !lua }, () => {
|
|
const out = runLua(cbiPrelude + `
|
|
for _, name in ipairs({'general', 'ddns', 'acme', 'proxy', 'login_guard'}) do
|
|
options = {}
|
|
local m = page(name)
|
|
assert(m.title:match('^MiniGate'), name .. ' title')
|
|
for key, o in pairs(options) do
|
|
if o.kind == 'DummyValue' and type(o.cfgvalue) == 'function' then
|
|
assert(type(o.cfgvalue(o, 'cfg01')) == 'string', name .. '.' .. key)
|
|
end
|
|
end
|
|
end
|
|
options = {}; page('ddns')
|
|
print(options._info.cfgvalue(options._info, 'cfg01'):match('不是公网地址') ~= nil)
|
|
options = {}; page('acme')
|
|
local certs = options._certs.cfgvalue(options._certs)
|
|
print(certs:match('剩 5 天') ~= nil, certs:match('测试证书') ~= nil, certs:match('测试模式已开启') ~= nil)
|
|
options = {}; page('general')
|
|
print(options.enabled ~= nil, options.ipv6_listen == nil)
|
|
`);
|
|
assert.equal(out, 'true\ntrue\ttrue\ttrue\ntrue\ttrue');
|
|
});
|
|
|
|
test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whitelists', { skip: !lua }, () => {
|
|
const out = runLua(cbiPrelude + `
|
|
local function ok(o, v) return (o.validate(o, v, 'cfg01')) ~= nil end
|
|
options = {}; page('ddns')
|
|
print(ok(options.domain, 'home.example.com'), ok(options.domain, '*.example.com'), ok(options.domain, 'bad domain.com'),
|
|
ok(options.domain, '-a.example.com'), ok(options.domain, 'localhost'))
|
|
print(ok(options.cf_zone_id, string.rep('a1', 16)), ok(options.cf_zone_id, 'zone'), ok(options.cf_api_token, 'abc_DEF-123'), ok(options.cf_api_token, 'abc def'))
|
|
print(ok(options.check_interval, '300'), ok(options.check_interval, '60'))
|
|
options = {}; page('proxy')
|
|
print(ok(options.prefix, 'app'), ok(options.prefix, 'a.b'), ok(options.prefix, '-x'), options.target_addr.datatype)
|
|
options = {}; page('login_guard')
|
|
print(options.threshold.datatype, ok(options.whitelist, {'203.0.113.0/20', '2001:db8::/48', '8.8.8.8'}), ok(options.whitelist, {'1.2.3.0/255.255.255.0'}), ok(options.whitelist, '10.0.0.0/33'))
|
|
`);
|
|
assert.equal(out, [
|
|
'true\ttrue\tfalse\tfalse\tfalse',
|
|
'true\tfalse\ttrue\tfalse',
|
|
'true\tfalse',
|
|
'true\tfalse\tfalse\thost',
|
|
'range(1,100)\ttrue\tfalse\tfalse'
|
|
].join('\n'));
|
|
});
|