Files
op-packages/luci-app-substore/tests/controller_robustness_test.lua
T

404 lines
16 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- controller_robustness_test.lua — 控制器健壮性回归测试
-- 用法:lua5.1 tests/controller_robustness_test.lua
--
-- 覆盖两项:
-- L3 重复表单字段(POST)会让 formvalue 返回 **table** 而非 string,
-- 控制器直接对它 :gsub / util.trim 会抛错 → HTTP 500。
-- 依据上游 luci/http.lua 的 urldecode_message_body:
-- elseif what == parser.VALUE and name then
-- local val = msg.params[name]
-- if type(val) == "table" then val[#val+1] = ...
-- elseif val ~= nil then msg.params[name] = { val, ... } -- ← 变 table
-- 注意 GET 查询串走的是 urldecode_params,它只做覆盖、不建表,
-- 所以触发条件是 POST 重复字段。
-- L4 combo_save / delete / update / node_delete 的失败被静默吞掉,
-- 违反项目 §18(失败必须让用户看见)。
--
-- 不需要真实 LuCI:用最小 stub 提供 luci.http / luci.dispatcher / luci.util,
-- substore.core 换成可控假实现,然后直接调用控制器的 action_* 函数。
package.path = "./root/usr/share/?.lua;" .. package.path
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
-- ---------- luci stub ----------
local FORM = {}
local LAST_REDIRECT = nil
local LAST_WRITE = nil
_G.luci = {
http = {
formvalue = function(k) return FORM[k] end,
redirect = function(url) LAST_REDIRECT = url end,
write = function(s) LAST_WRITE = s end,
status = function() end,
header = function() end,
prepare_content = function() end,
},
dispatcher = {
build_url = function(...)
local t = { ... }
return "/" .. table.concat(t, "/")
end,
},
util = {
urlencode = function(s)
return (tostring(s or ""):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end,
pcdata = function(s) return tostring(s or "") end,
},
i18n = { translate = function(s) return s end },
}
_G.luci.controller = { admin = {} }
package.loaded["luci.http"] = _G.luci.http
package.loaded["luci.dispatcher"] = _G.luci.dispatcher
package.loaded["luci.util"] = _G.luci.util
package.loaded["luci.i18n"] = _G.luci.i18n
-- ---------- substore 依赖 stub ----------
local CORE = {}
package.loaded["substore.core"] = CORE
-- 控制器在 read_rules_fields 里会调用 node.validate_rename_map 校验重命名规则
-- (非法正则要在保存时报错,而不是静默不生效)。stub 必须提供同名函数,
-- 否则整条保存路径都会因 nil 调用而 500 —— 那是 stub 缺口,不是产品代码缺陷。
local NODE = {
filter = function(ns) return ns end,
sort = function(ns) return ns end,
validate_rename_map = function() return true end,
}
package.loaded["substore.node"] = NODE
package.loaded["substore.probe"] = {
probe = function() return {} end,
}
local function core_reset()
CORE.add = function() return "s0000000a" end
CORE.add_local = function() return "s0000000a" end
CORE.remove = function() return true end
CORE.add_combo = function() return "s0000000a" end
CORE.save_combo = function() return "s0000000a" end
CORE.get = function() return { id = "s00000001", name = "x" } end
CORE.list = function() return {} end
CORE.read_nodes = function() return {} end
CORE.write_nodes = function() return true end
CORE.save_meta = function() return true end
CORE.refresh_combos = function() end
CORE.sync = function() return true end
CORE.write_cron = function() end
CORE.cron_time_valid = function() return true end
-- 控制器在 read_ua_fields 里调用它解析「订阅客户端类型」(预设 key → UA 字符串)。
-- 与上面的 validate_rename_map 同理:stub 缺这个函数会让整条保存路径 nil 调用。
-- 这里返回 ""(不设置 UA),让用例聚焦各自要覆盖的字段。
CORE.resolve_user_agent = function() return "" end
CORE.UA_PRESETS = { { key = "clash-verge", label = "Clash Verge", ua = "clash-verge/v2.5.0" } }
CORE.generate_link = function() return "body", "text/plain", "f.txt" end
CORE.RULE_PROTOS = { "vmess", "trojan" }
end
core_reset()
local CTL_PATH = os.getenv("SUBSTORE_CONTROLLER")
or "root/usr/lib/lua/luci/controller/admin/substore.lua"
local ok, err = pcall(dofile, CTL_PATH)
check("controller loads under stub", ok)
if not ok then print(" load error: " .. tostring(err)) end
local ctl = package.loaded["luci.controller.admin.substore"] or {}
local function reset()
FORM = {}
LAST_REDIRECT = nil
LAST_WRITE = nil
core_reset()
end
local function has_err()
return type(LAST_REDIRECT) == "string" and LAST_REDIRECT:find("err=", 1, true) ~= nil
end
-- 所有 action 的入口清单(带一组能走到底的合法表单)
local ACTIONS = {
{ "action_create", { token = "t", name = "n", url = "http://e.com/s" } },
{ "action_save", { token = "t", id = "s00000001", name = "n", url = "http://e.com/s" } },
{ "action_local_create", { token = "t", name = "n", content = "vmess://x" } },
{ "action_local_save", { token = "t", id = "s00000001", name = "n", content = "vmess://x" } },
{ "action_delete", { token = "t", id = "s00000001" } },
{ "action_node_save", { token = "t", id = "s00000001", idx = "1", content = "[]" } },
{ "action_node_delete", { token = "t", id = "s00000001", idx = "1" } },
{ "action_node_set_group", { token = "t", id = "s00000001", idx = "1", group = "g" } },
{ "action_combo_save", { token = "t", name = "n" } },
{ "action_update", { token = "t", id = "s00000001" } },
{ "action_probe", { token = "t", id = "s00000001", mode = "ping" } },
{ "action_download", { token = "t", target = "ClashMeta" } },
}
-- ---------- L3:每个字段都重复提交一次,action 不得抛错 ----------
-- 真实场景:POST body 里同名字段出现两次(表单里出现同名 input,
-- 或攻击者手工构造)。此时 formvalue 返回 {v1, v2}。
for _, a in ipairs(ACTIONS) do
local action, form = a[1], a[2]
reset()
local dup = {}
for k, v in pairs(form) do dup[k] = { v, v .. "2" } end
FORM = dup
local ok2, e = pcall(ctl[action])
check(action .. " survives duplicated form fields" .. (ok2 and "" or (" (" .. tostring(e) .. ")")), ok2)
end
-- 同一个 action 里全部字段都重复(最坏情况)
reset()
FORM = {}
for k, v in pairs(ACTIONS[1][2]) do FORM[k] = { v, v } end
FORM.id = { "s00000001", "s00000002" }
local ok3, e3 = pcall(ctl.action_save)
check("action_save survives all-duplicated fields" .. (ok3 and "" or (" (" .. tostring(e3) .. ")")), ok3)
-- 重复字段取值语义:取最后一个(后者覆盖前者),而不是报错或取第一个
reset()
FORM = { token = "t", id = "s00000001", name = { "first", "second" }, url = "http://e.com/s" }
local seen_name = nil
CORE.save_meta = function(_, patch) seen_name = patch.name; return true end
pcall(ctl.action_save)
check("duplicated field keeps last value", seen_name == "second")
-- ---------- L4:失败必须回显 ----------
-- action_delete:core.remove 返回 false
reset()
CORE.remove = function() return false, "订阅不存在" end
FORM = { token = "t", id = "s00000001" }
ctl.action_delete()
check("delete failure reports error", has_err())
-- action_delete:成功路径不带 err
reset()
FORM = { token = "t", id = "s00000001" }
ctl.action_delete()
check("delete success has no err", not has_err())
-- ---------- 勾选批量删除:id 支持逗号分隔多个 ----------
-- 行内删除按钮提交单个 id(上面两条覆盖);表头的「删除」按钮把勾选的多个
-- id 用逗号拼起来提交,走同一个 action —— 与节点页 action_node_delete 的
-- idx 是同一套约定。
reset()
local seen_ids = {}
CORE.remove = function(id) seen_ids[#seen_ids + 1] = id; return true end
FORM = { token = "t", id = "s00000001,s00000002,s00000003" }
ctl.action_delete()
check("multi delete removes every id",
#seen_ids == 3 and seen_ids[1] == "s00000001" and seen_ids[3] == "s00000003")
check("multi delete success has no err", not has_err())
-- 逗号两侧的空格不影响切分(浏览器不会加空格,手工构造 / 复制粘贴会)
reset()
seen_ids = {}
CORE.remove = function(id) seen_ids[#seen_ids + 1] = id; return true end
FORM = { token = "t", id = " s00000001 , s00000002 " }
ctl.action_delete()
check("delete tolerates spaces around ids",
#seen_ids == 2 and seen_ids[1] == "s00000001" and seen_ids[2] == "s00000002")
-- 批量删除成功后写一次 cron:删掉的订阅不能继续留在 /etc/cron.d/substore 里
-- 断言里同时数 core.remove 的调用次数:只断言 write_cron 的话,改动前的实现
-- (把整串 id 当成一个 id 传下去)也会通过 —— stub 对任何 id 都返回 true。
reset()
local cron_writes, rm_calls = 0, {}
CORE.remove = function(id) rm_calls[#rm_calls + 1] = id; return true end
CORE.write_cron = function() cron_writes = cron_writes + 1 end
FORM = { token = "t", id = "s00000001,s00000002" }
ctl.action_delete()
check("multi delete rewrites cron once after removing both",
cron_writes == 1 and #rm_calls == 2)
-- 部分失败必须回显:勾了 3 个只删掉 1 个,不能显示成「成功」。
-- 同样要数调用次数,否则改动前的实现也会「通过」。
--
-- 同时断言 cron 被重写:删掉的那 1 个订阅的 cron 行必须清掉,
-- 否则 substore-cron.sh 会拿着已不存在的 id 反复失败(退出码非 0)。
reset()
local partial_calls, partial_cron = 0, 0
CORE.remove = function(id) partial_calls = partial_calls + 1; return id == "s00000001" end
CORE.write_cron = function() partial_cron = partial_cron + 1 end
FORM = { token = "t", id = "s00000001,s00000002,s00000003" }
ctl.action_delete()
check("partial delete attempts every id and reports error",
has_err() and partial_calls == 3)
check("partial delete rewrites cron for the removed ones", partial_cron == 1)
-- 全部失败:一个都没删掉 → 没有任何订阅的 cron 行需要清理,不重写
reset()
local none_cron = 0
CORE.remove = function() return false, "订阅不存在" end
CORE.write_cron = function() none_cron = none_cron + 1 end
FORM = { token = "t", id = "s00000001,s00000002" }
ctl.action_delete()
check("all-failed delete does not rewrite cron", none_cron == 0)
-- 全部失败:回显 core.remove 给出的原因
reset()
CORE.remove = function() return false, "订阅不存在" end
FORM = { token = "t", id = "s00000001,s00000002" }
ctl.action_delete()
check("all-failed delete reports core reason", has_err())
-- 一个可用 id 都没有:不能静默跳回(否则用户以为删了)
reset()
FORM = { token = "t", id = " , " }
ctl.action_delete()
check("delete with no usable id reports error", has_err())
-- 只有分隔符的 id 不得被当成「一个叫 ',' 的订阅」交给 core
reset()
local called = 0
CORE.remove = function() called = called + 1; return true end
FORM = { token = "t", id = ",,," }
ctl.action_delete()
check("delete does not pass empty ids to core", called == 0)
-- action_combo_save:名称为空
reset()
FORM = { token = "t", name = "" }
ctl.action_combo_save()
check("combo_save empty name reports error", has_err())
-- action_combo_save:add_combo 失败
reset()
CORE.add_combo = function() return nil, "请选择至少一个订阅" end
FORM = { token = "t", name = "n" }
ctl.action_combo_save()
check("combo_save create failure reports error", has_err())
-- action_combo_save:save_combo 失败
reset()
CORE.save_combo = function() return nil, "非法 ID" end
FORM = { token = "t", id = "s00000001", name = "n" }
ctl.action_combo_save()
check("combo_save update failure reports error", has_err())
-- action_combo_save:成功路径不带 err
reset()
FORM = { token = "t", name = "n" }
ctl.action_combo_save()
check("combo_save success has no err", not has_err())
-- action_update:订阅不存在
reset()
CORE.get = function() return nil end
FORM = { token = "t", id = "s00000001" }
ctl.action_update()
check("update missing subscription reports error", has_err())
-- action_update:sync 失败仍然写回 error(原有行为,加守卫)
reset()
local written = nil
CORE.sync = function() return nil, "无法识别的订阅格式" end
CORE.save_meta = function(_, patch) written = patch; return true end
FORM = { token = "t", id = "s00000001" }
ctl.action_update()
check("update sync failure writes error to meta",
written ~= nil and written.error == "无法识别的订阅格式")
-- action_node_delete:没有可解析的下标
reset()
FORM = { token = "t", id = "s00000001", idx = "abc" }
ctl.action_node_delete()
check("node_delete no valid idx reports error", has_err())
-- action_node_delete:下标越界(节点不存在)
reset()
CORE.read_nodes = function() return { { name = "only" } } end
FORM = { token = "t", id = "s00000001", idx = "9" }
ctl.action_node_delete()
check("node_delete out-of-range idx reports error", has_err())
-- action_node_delete:写盘失败
reset()
CORE.read_nodes = function() return { { name = "a" }, { name = "b" } } end
CORE.write_nodes = function() return false end
FORM = { token = "t", id = "s00000001", idx = "1" }
ctl.action_node_delete()
check("node_delete write failure reports error", has_err())
-- action_node_delete:成功路径不带 err
reset()
CORE.read_nodes = function() return { { name = "a" }, { name = "b" } } end
FORM = { token = "t", id = "s00000001", idx = "1" }
ctl.action_node_delete()
check("node_delete success has no err", not has_err())
-- ---------- M28:CSRF token 必须真的校验 ----------
-- 上游 LuCI 渲染表单时模板变量 `token` 就是 luci.dispatcher.context.authtoken
-- (luci-lua-runtime/luasrc/template.lua 的 viewns 元表),
-- 所以「提交的 token == authtoken」正是框架自己那套判定,不会误拒合法表单。
-- 此前的判定是 `token ~= nil`:`token=`(空串)也能通过,等于没有校验。
_G.luci.dispatcher.context = { authtoken = "secret" }
-- 合法表单必须照常通过(这条是关键:改了校验不能把正常提交挡在门外)
reset()
local ok_calls = 0
CORE.remove = function() ok_calls = ok_calls + 1; return true end
FORM = { token = "secret", id = "s00000001" }
ctl.action_delete()
check("M28 valid token accepted", ok_calls == 1 and not has_err())
-- 空串 token:此前被 `~= nil` 放过,现在必须拒绝
reset()
local empty_calls = 0
CORE.remove = function() empty_calls = empty_calls + 1; return true end
FORM = { token = "", id = "s00000001" }
ctl.action_delete()
check("M28 empty token rejected", empty_calls == 0)
check("M28 empty token reports error", has_err())
-- 不匹配的 token 同样拒绝
reset()
local bad_calls = 0
CORE.remove = function() bad_calls = bad_calls + 1; return true end
FORM = { token = "wrong", id = "s00000001" }
ctl.action_delete()
check("M28 mismatched token rejected", bad_calls == 0)
check("M28 mismatched token reports error", has_err())
-- 校验失败必须对所有动作生效(此前 9 个动作都是静默重定向)
reset()
local created = 0
CORE.add = function() created = created + 1; return "s0000000a" end
FORM = { token = "", name = "n", url = "http://e.com/s" }
ctl.action_create()
check("M28 create rejects empty token", created == 0 and has_err())
reset()
local updated = 0
CORE.sync = function() updated = updated + 1; return true end
FORM = { token = "wrong", id = "s00000001" }
ctl.action_update()
check("M28 update rejects bad token", updated == 0 and has_err())
-- 节点页动作走 back_to_nodes,同样要带 err
reset()
local wrote = 0
CORE.read_nodes = function() return { { name = "a" } } end
CORE.write_nodes = function() wrote = wrote + 1; return true end
FORM = { token = "", id = "s00000001", idx = "1", content = "[]" }
ctl.action_node_save()
check("M28 node_save rejects empty token",
wrote == 0 and LAST_REDIRECT:find("err=", 1, true) ~= nil)
-- 取不到 authtoken(老版本 LuCI / 非标准上下文)时退回原判定,
-- 不能因为拿不到框架内部字段就把所有表单都拒掉。
_G.luci.dispatcher.context = nil
reset()
local legacy_calls = 0
CORE.remove = function() legacy_calls = legacy_calls + 1; return true end
FORM = { token = "t", id = "s00000001" }
ctl.action_delete()
check("M28 falls back when authtoken unavailable",
legacy_calls == 1 and not has_err())
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)