mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 07:32:51 +08:00
310 lines
13 KiB
Lua
310 lines
13 KiB
Lua
-- p2_batch7_test.lua — P2 批次七回归测试(L1 / L6 / L8 / L9 / L23 / L24 / L25 / L26)
|
||
-- 用法:lua5.1 tests/p2_batch7_test.lua
|
||
--
|
||
-- 全部离线:涉及子进程的地方一律替换 io.popen / os.execute 为记录桩。
|
||
-- L5(视图 < 转义)的断言在 tests/view_injection_test.lua 里,不在此重复。
|
||
|
||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||
|
||
local util = require("substore.util")
|
||
local node = require("substore.node")
|
||
local output = require("substore.output")
|
||
local probe = require("substore.probe")
|
||
local msg = require("substore.msg")
|
||
|
||
local passed, failed = 0, 0
|
||
local function check(name, cond)
|
||
if cond then passed = passed + 1; print("PASS " .. name)
|
||
else failed = failed + 1; print("FAIL " .. name) end
|
||
end
|
||
|
||
local function read(p)
|
||
return util.read_file(p) or ""
|
||
end
|
||
|
||
-- ---------- L1:界面 msgid 一律用英文(中文只在 po 里做译文) ----------
|
||
--
|
||
-- 用中文当 msgid 且 po 里没有对应条目时,LuCI 在英文界面会**原样显示中文**。
|
||
-- 这里直接扫模板:`<%:...%>` 与 `luci.i18n.translate("...")` 里的字符串不得含 CJK。
|
||
local function has_cjk(s)
|
||
-- UTF-8 里的 CJK 统一落在 U+4E00..U+9FFF(三字节 E4B8 80 .. E9BF BF)。
|
||
-- 按字节扫:任意一个三字节序列落在该区间即判定为中文。
|
||
for i = 1, #s - 2 do
|
||
local b1, b2, b3 = s:byte(i), s:byte(i + 1), s:byte(i + 2)
|
||
if b1 and b2 and b3 and b1 >= 0xE4 and b1 <= 0xE9
|
||
and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
local VIEW_DIR = "root/usr/lib/lua/luci/view/substore/"
|
||
local view_files = {
|
||
"subscriptions.htm", "nodes.htm", "node_edit.htm", "local_form.htm",
|
||
"form.htm", "combo.htm", "output.htm",
|
||
}
|
||
local bad_msgids = {}
|
||
for _, f in ipairs(view_files) do
|
||
local src = read(VIEW_DIR .. f)
|
||
for i, line in ipairs((function()
|
||
local t = {}
|
||
for l in (src .. "\n"):gmatch("([^\n]*)\n") do t[#t + 1] = l end
|
||
return t
|
||
end)()) do
|
||
-- 跳过 Lua 注释行(模板里 <%% 之外的 -- 行),注释里可以写中文
|
||
if not line:match("^%s*%-%-") then
|
||
for msgid in line:gmatch("<%%:(.-)%%>") do
|
||
if has_cjk(msgid) then
|
||
bad_msgids[#bad_msgids + 1] = f .. ":" .. i .. " " .. msgid
|
||
end
|
||
end
|
||
for msgid in line:gmatch('luci%.i18n%.translate%("(.-)"%)') do
|
||
if has_cjk(msgid) then
|
||
bad_msgids[#bad_msgids + 1] = f .. ":" .. i .. " " .. msgid
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
check("no CJK msgid left in view templates", #bad_msgids == 0)
|
||
if #bad_msgids > 0 then
|
||
for _, b in ipairs(bad_msgids) do print(" " .. b) end
|
||
end
|
||
|
||
-- 新英文 msgid 必须在 zh-cn 译文表里有条目,否则中文界面会显示英文
|
||
local po = read("po/zh_Hans/substore.po")
|
||
check("po has Operation failed", po:find('msgid "Operation failed"', 1, true) ~= nil)
|
||
check("po has Pick a format msgid",
|
||
po:find("Pick a format to generate the subscription link", 1, true) ~= nil)
|
||
check("po Operation failed translated", po:find('msgstr "操作失败"', 1, true) ~= nil)
|
||
-- 反向对照:这两个 msgid 在改动前是中文的,确认它们确实来自本轮改动
|
||
check("po still has Type", po:find('msgid "Type"', 1, true) ~= nil)
|
||
|
||
-- ---------- L6:批量探测必须限制并发 ----------
|
||
--
|
||
-- 原来「先起完全部 io.popen 再统一读取」,节点数上千时进程/fd 无界增长。
|
||
-- 用记录桩统计**同时存活**的句柄峰值。
|
||
check("MAX_PARALLEL defined", type(probe.MAX_PARALLEL) == "number" and probe.MAX_PARALLEL > 0)
|
||
|
||
local real_popen = io.popen
|
||
local live, peak, opened = 0, 0, 0
|
||
io.popen = function()
|
||
live = live + 1
|
||
opened = opened + 1
|
||
if live > peak then peak = live end
|
||
return {
|
||
read = function() return "64 bytes from 127.0.0.1: time=1.5 ms" end,
|
||
close = function() live = live - 1 end,
|
||
}
|
||
end
|
||
local many = {}
|
||
for i = 1, 100 do
|
||
many[i] = { name = "n" .. i, server = "127.0.0.1", port = 80 }
|
||
end
|
||
local res = probe.probe(many, "ping")
|
||
io.popen = real_popen
|
||
|
||
check("probe returns one result per node", #res == 100)
|
||
check("probe spawned one process per node", opened == 100)
|
||
-- 用局部变量兜住 nil:MAX_PARALLEL 不存在时(改动前)下面两条断言要能报 FAIL
|
||
-- 而不是让整个脚本因「compare number with nil」中断,否则后面的断言全看不到。
|
||
local cap = tonumber(probe.MAX_PARALLEL) or -1
|
||
check("probe concurrency capped", peak <= cap)
|
||
check("probe actually parallel (not serial)", peak == cap)
|
||
check("probe closed every handle", live == 0)
|
||
check("probe parsed latency", res[1].latency == 1.5)
|
||
check("probe keeps order", res[1].name == "n1" and res[100].name == "n100")
|
||
|
||
-- ---------- L8:空 format 不能被当成合法格式名 ----------
|
||
local tpl_nodes = { {
|
||
name = "n", proto = "vmess", server = "1.1.1.1", port = 443,
|
||
uuid = "11111111-1111-4111-8111-111111111111", alterId = 0,
|
||
} }
|
||
local def_body, def_err = output.generate(tpl_nodes, "clashmeta")
|
||
check("baseline format works", type(def_body) == "string" and def_err == nil)
|
||
|
||
local empty_body, empty_err = output.generate(tpl_nodes, "")
|
||
check("empty format does not error", empty_err == nil)
|
||
check("empty format equals default", empty_body == def_body)
|
||
check("whitespace format equals default", output.generate(tpl_nodes, " ") == def_body)
|
||
check("tab/newline format equals default", output.generate(tpl_nodes, "\t\n ") == def_body)
|
||
-- nil 与非法名仍按原语义走
|
||
check("nil format equals default", output.generate(tpl_nodes, nil) == def_body)
|
||
local _, bad_err = output.generate(tpl_nodes, "nosuchformat")
|
||
check("unknown format still errors", type(bad_err) == "string" and bad_err:find("nosuchformat", 1, true) ~= nil)
|
||
|
||
-- ---------- L9:output.htm / combo.htm 的安装位置 ----------
|
||
--
|
||
-- 这两行原来挂在 /www/luci-static 的 INSTALL_DIR 块下面 —— 目标路径本身是对的,
|
||
-- 但归错了块,一旦有人调整块顺序就会被装到错误的目录。
|
||
local mk = read("Makefile")
|
||
local view_dir_at = mk:find("$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/substore", 1, true)
|
||
local static_dir_at = mk:find("$(INSTALL_DIR) $(1)/www/luci-static/resources/substore", 1, true)
|
||
local out_htm_at = mk:find("view/substore/output.htm", 1, true)
|
||
local combo_htm_at = mk:find("view/substore/combo.htm", 1, true)
|
||
check("Makefile has view INSTALL_DIR", view_dir_at ~= nil)
|
||
check("Makefile has luci-static INSTALL_DIR", static_dir_at ~= nil)
|
||
check("output.htm installed in view block",
|
||
out_htm_at ~= nil and out_htm_at > view_dir_at and out_htm_at < static_dir_at)
|
||
check("combo.htm installed in view block",
|
||
combo_htm_at ~= nil and combo_htm_at > view_dir_at and combo_htm_at < static_dir_at)
|
||
|
||
-- ---------- L23:trojan → vmess/vless 必须生成合法 UUID ----------
|
||
local UUID_RE = "^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4%x%x%x%-[89ab]%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$"
|
||
|
||
-- 这几个函数是本轮新增的,在改动前的代码上并不存在。用局部兜底而不是直接调用:
|
||
-- 否则整个脚本会在第一次 nil 调用处中断,后面所有断言都跑不到,
|
||
-- 反向验证就只剩半份报告(看不清哪些新断言真的失败)。
|
||
local uuid = util.uuid or function() return nil end
|
||
local gsub_literal = util.gsub_literal or function() return nil end
|
||
local validate_rename_map = node.validate_rename_map or function() return true end
|
||
|
||
local u = uuid()
|
||
check("util.uuid shape", type(u) == "string" and u:match(UUID_RE) ~= nil)
|
||
check("util.uuid unique-ish", uuid() ~= uuid())
|
||
|
||
-- L25 原先经 converter.convert_protocol 验证「转换时生成的 UUID 不是
|
||
-- base64(seed) 的形状」。converter / node_converter 已作为死代码删除
|
||
-- (见 docs/LEGACY_ISSUES.md 1.4),这里改为直接验证 util.uuid 本身 ——
|
||
-- 那才是唯一在用的实现,形状要求与当初一致:36 字符、只含十六进制与连字符。
|
||
-- 反向对照:旧实现是 base64(seed) 的前 36 字符,而 base64 只产出 24 个字符,
|
||
-- 长度必然不等于 36 且含非十六进制字符。
|
||
check("util.uuid not base64-shaped",
|
||
u:find("[+/=]", 1) == nil and #u == 36)
|
||
|
||
-- ---------- L24:gsub 替换串必须按字面处理 ----------
|
||
check("gsub_literal escapes percent", gsub_literal("100%") == "100%%")
|
||
check("gsub_literal empty", gsub_literal(nil) == "")
|
||
|
||
-- node.apply_rules 的 {server} 模板占位符同样要按字面替换。
|
||
-- 该分支由 rules.template_apply 开关控制,必须显式打开。
|
||
local TMPL_ON = { template_apply = true }
|
||
local tmpl_nodes = { {
|
||
name = "tpl", proto = "vmess", server = "1.2.3.4", port = 443,
|
||
template = "https://sub.example.com/{server}:{port}",
|
||
} }
|
||
local applied = node.apply_rules(tmpl_nodes, TMPL_ON)
|
||
check("apply_rules template expands server",
|
||
applied[1].url == "https://sub.example.com/1.2.3.4:443")
|
||
local tmpl_nodes2 = { {
|
||
name = "tpl", proto = "vmess", server = "100%host", port = 443,
|
||
template = "https://x/{server}",
|
||
} }
|
||
check("apply_rules template keeps percent literal",
|
||
node.apply_rules(tmpl_nodes2, TMPL_ON)[1].url == "https://x/100%host")
|
||
-- 已有 url 的节点不得被模板覆盖(模板只是「补一个 url」,不是改写)
|
||
local tmpl_nodes3 = { {
|
||
name = "tpl", proto = "vmess", server = "1.2.3.4", port = 443,
|
||
template = "https://x/{server}", url = "https://keep.me/",
|
||
} }
|
||
check("apply_rules template leaves existing url alone",
|
||
node.apply_rules(tmpl_nodes3, TMPL_ON)[1].url == "https://keep.me/")
|
||
|
||
-- ---------- L25:非法重命名正则必须在保存时报错 ----------
|
||
local ok_valid, err_valid = validate_rename_map("a -> b")
|
||
check("valid regex accepted", ok_valid == true and err_valid == nil)
|
||
check("empty rules accepted", validate_rename_map("") == true)
|
||
check("exact rule accepted", validate_rename_map("OLD=NEW") == true)
|
||
check("placeholder rule accepted", validate_rename_map("{server}_{port}_{proto}") == true)
|
||
|
||
local ok_bad, err_bad = validate_rename_map("[abc -> x")
|
||
check("invalid regex rejected", ok_bad == false)
|
||
-- 报错是 msg.compose 拼出来的**组合消息**(后端不能 require luci.i18n,见 msg.lua):
|
||
-- 行号是独立一段,整串直接 find 会跨不过分隔符。按显示边界的方式还原成用户看到的文案再断言。
|
||
local function rendered(s) return msg.translate(s, function(k) return k end) end
|
||
check("invalid regex error names the line",
|
||
type(err_bad) == "string" and rendered(err_bad):find("line 1", 1, true) ~= nil)
|
||
check("invalid regex error quotes the pattern",
|
||
type(err_bad) == "string" and err_bad:find("[abc", 1, true) ~= nil)
|
||
|
||
-- 行号要指向真正出错的那一行,而不是恒为 1
|
||
local ok_multi, err_multi = validate_rename_map("good -> ok\n[bad -> x\nlast -> y")
|
||
check("line number points at the bad rule",
|
||
ok_multi == false and type(err_multi) == "string"
|
||
and rendered(err_multi):find("line 2", 1, true) ~= nil)
|
||
|
||
-- 校验必须覆盖**每一条**备选分支(split_alternatives 展开后的每一项)
|
||
check("invalid alternative rejected", validate_rename_map("[a|b -> x") == false)
|
||
|
||
-- ---------- L26:数据文件与目录权限 ----------
|
||
--
|
||
-- Lua 5.1 没有 os.chmod,实现走 busybox chmod;这里替换 os.execute 记录命令行。
|
||
local real_execute = os.execute
|
||
local exec_log = {}
|
||
os.execute = function(cmd)
|
||
exec_log[#exec_log + 1] = cmd
|
||
return 0
|
||
end
|
||
|
||
local tmpfile = "/tmp/substore_l26_probe.json"
|
||
os.remove(tmpfile)
|
||
local wok = util.atomic_write(tmpfile, "{}", "600")
|
||
os.remove(tmpfile)
|
||
check("atomic_write with mode succeeds", wok == true)
|
||
|
||
local chmod_after_rename = false
|
||
for _, cmd in ipairs(exec_log) do
|
||
if cmd:find("chmod 600", 1, true) and cmd:find(tmpfile, 1, true) then
|
||
chmod_after_rename = true
|
||
end
|
||
end
|
||
check("atomic_write chmods the target 600", chmod_after_rename)
|
||
|
||
-- 不带 mode 时不得额外 chmod(cron 文件走的就是这条路,权限语义要保持不变)
|
||
exec_log = {}
|
||
os.remove(tmpfile)
|
||
util.atomic_write(tmpfile, "{}")
|
||
os.remove(tmpfile)
|
||
local stray = false
|
||
for _, cmd in ipairs(exec_log) do
|
||
if cmd:find("chmod", 1, true) then stray = true end
|
||
end
|
||
check("atomic_write without mode does not chmod", stray == false)
|
||
|
||
-- ensure_dir 的 mode 只在**新建**时由 mkdir -m 生效
|
||
exec_log = {}
|
||
util.ensure_dir("/tmp/substore_l26_dir", "700")
|
||
local saw_mkdir_m = false
|
||
for _, cmd in ipairs(exec_log) do
|
||
if cmd:find("mkdir -p -m 700", 1, true) then saw_mkdir_m = true end
|
||
end
|
||
check("ensure_dir passes -m 700", saw_mkdir_m)
|
||
|
||
exec_log = {}
|
||
util.ensure_dir("/tmp/substore_l26_dir")
|
||
local saw_plain_mkdir = false
|
||
for _, cmd in ipairs(exec_log) do
|
||
if cmd:find("mkdir -p ", 1, true) and not cmd:find(" -m ", 1, true) then saw_plain_mkdir = true end
|
||
end
|
||
check("ensure_dir without mode stays plain", saw_plain_mkdir)
|
||
|
||
-- core.ensure_dirs:新建目录带 700,且对**已存在**的目录补一次 chmod 700
|
||
package.loaded["substore.core"] = nil
|
||
local core = require("substore.core")
|
||
exec_log = {}
|
||
core.ensure_dirs()
|
||
local saw_data_chmod, saw_nodes_chmod = false, false
|
||
for _, cmd in ipairs(exec_log) do
|
||
if cmd:find("chmod 700", 1, true) then
|
||
if cmd:find(core.DATA_DIR, 1, true) then saw_data_chmod = true end
|
||
if cmd:find(core.NODES_DIR, 1, true) then saw_nodes_chmod = true end
|
||
end
|
||
end
|
||
check("ensure_dirs chmods DATA_DIR 700", saw_data_chmod)
|
||
check("ensure_dirs chmods NODES_DIR 700", saw_nodes_chmod)
|
||
|
||
-- 第二次调用不得重复 chmod(load() 每次读列表都会走到这里)
|
||
local before = #exec_log
|
||
core.ensure_dirs()
|
||
local chmods_again = 0
|
||
for i = before + 1, #exec_log do
|
||
if exec_log[i]:find("chmod", 1, true) then chmods_again = chmods_again + 1 end
|
||
end
|
||
check("ensure_dirs chmods only once per process", chmods_again == 0)
|
||
|
||
os.execute = real_execute
|
||
|
||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||
os.exit(failed == 0 and 0 or 1)
|