Files
op-packages/smart-srun/tests/lua/controller_actions.lua
T

354 lines
18 KiB
Lua

-- Status, manual actions and account editing through the real controller.
--
-- Every one of these used to be a file the page wrote. What is asserted now is
-- the request that leaves the page and the answer that reaches the browser: the
-- account the action names, the revision it carries, the credential it does not
-- send, and the refusal it does not swallow.
local repo = ... or "."
local harness = dofile(repo .. "/tests/lua/controller_harness.lua")(repo)
local controller = harness.controller
local PORTAL_URL = "http://portal.example.invalid/srun_portal_pc"
local CONFIG = {
revision = 11,
selection = { active_campus_id = "c1", default_campus_id = "c2",
active_hotspot_id = "h1", default_hotspot_id = "h1" },
campus_accounts = {
{ id = "c1", label = "宿舍", user_id = "student", access_mode = "wired",
wired_iface = "wan", auth_enabled = false, login = {} },
{ id = "c2", label = "教学楼", user_id = "student", access_mode = "wifi",
ssid = "Campus", login = {} },
},
hotspot_profiles = { { id = "h1", label = "手机热点", ssid = "iPhone" } },
}
local RUNNING = {
service = "running", enabled = true, config_revision = 11,
written_at = "2026-09-18T12:00:05Z",
accounts = { { account_id = "c1", link = "Ready", auth = "Rejected",
connectivity = "PortalReachable", identity = "" } },
actions = { { id = "a1", kind = "manual_login", state = "failed",
message = "用户名或密码错误", ended_at = "2026-09-18T12:00:00Z" } },
}
harness.responses["config.get"] = CONFIG
harness.responses["status.get"] = RUNNING
harness.reset()
harness.responses["config.get"] = { error = { code = "Unavailable", message = "配置读取暂时失败" } }
controller.action_status()
assert(harness.output.ok == false, "a configuration failure must not appear as an empty configuration")
assert(harness.output.service == "unknown", "a read failure cannot confirm that the service stopped")
assert(harness.output.status == "配置读取暂时失败", harness.output.status)
assert(#harness.calls == 2 and #harness.helpers == 0)
for _, call in ipairs(harness.calls) do assert(not call.started) end
harness.responses["config.get"] = CONFIG
-- The explanation of the last action survives routine status polling, which is
-- the failure the baseline hit: a daemon tick replaced it seconds after it
-- appeared, while the user was still reading it.
controller.action_status()
local status = harness.output
assert(status.last_action == "manual_login", status.last_action)
assert(status.action_result == "error", status.action_result)
assert(status.last_action_message == "用户名或密码错误", status.last_action_message)
assert(status.last_action_ts == harness.now - 5, status.last_action_ts)
assert(status.connectivity == "认证网关可达", status.connectivity)
assert(status.connectivity_level == "portal", status.connectivity_level)
assert(status.status == "认证被拒绝", status.status)
-- Portal evidence is not in the daemon's projection yet (batch B): the field is
-- published empty rather than filled with a guess.
assert(status.last_action_portal_url == "", status.last_action_portal_url)
assert(status.campus_account_label == "宿舍", status.campus_account_label)
assert(status.hotspot_profile_label == "手机热点", status.hotspot_profile_label)
-- A read must never start the service.
for _, call in ipairs(harness.calls) do
assert(call.started == false, call.method .. " must not start the service")
end
-- A stopped service is reported as a state, not as a page error.
harness.reset()
harness.responses["status.get"] = { error = { code = "ServiceStopped", message = "认证服务未在运行" } }
controller.action_status()
assert(harness.output.ok == false, "a failed status read must be explicit")
assert(harness.output.status == "认证服务未在运行", harness.output.status)
assert(harness.output.service == "stopped", harness.output.service)
assert(harness.output.connectivity_level == "offline")
harness.responses["status.get"] = RUNNING
-- Overview history is in completion order; wall-clock corrections must not
-- select an older result or uplink. An ID query remains independent of both.
local rolled = { service = "running", actions = {
{id = "old", kind = "switch_hotspot", state = "succeeded", ended_at = "2026-09-18T12:00:00Z"},
{id = "new", kind = "switch_campus", state = "succeeded", ended_at = "2026-09-18T11:00:00Z"},
} }
local rolled_view = harness.bridge.status_view(rolled, CONFIG)
assert(rolled_view.last_action == "switch_campus" and rolled_view.mode == "campus")
for _, state in ipairs({ "queued", "running", "succeeded", "failed", "cancelled", "interrupted" }) do
harness.reset()
harness.form = { action_id = "instance-a1" }
harness.responses["action.get"] = {id = "instance-a1", kind = "manual_login", state = state,
account_id = "c1", message = "this request", ended_at = "2026-09-18T11:00:00Z",
result = { secret = "private" }}
harness.responses["config.get"] = CONFIG
controller.action_status()
-- Polling costs one read. The one outcome that can offer the school's page
-- (a failed manual login) costs one more, of the already-redacted
-- configuration, and never starts the service.
if state == "failed" then
assert(#harness.calls == 2 and #harness.helpers == 0, #harness.calls)
assert(harness.calls[2].method == "config.get" and not harness.calls[2].started)
else
assert(#harness.calls == 1 and #harness.helpers == 0, #harness.calls)
end
local read = harness.calls[1]
assert(read.method == "action.get" and not read.started)
assert(read.params.action_id == "instance-a1" and read.params.session == nil)
assert(harness.output.action_id == "instance-a1" and harness.output.last_action_message == "this request")
local expected = ({queued = "pending", running = "pending", succeeded = "ok", failed = "error",
cancelled = "forced", interrupted = "forced"})[state]
assert(harness.output.action_result == expected)
assert(harness.output.result == nil)
end
for _, response in ipairs({
{id = "instance-a2", kind = "manual_login", state = "succeeded", message = "someone else"},
{id = "instance-a1", kind = "detect_operators", state = "succeeded", result = { secret = "private" }},
{id = "instance-a1", kind = "manual_login", state = "unknown"},
{error = {code = "NotFound"}}, {error = {code = "ServiceStopped", message = "认证服务未在运行"}},
}) do
harness.reset()
harness.responses["action.get"] = response
controller.action_status()
assert(harness.output.action_id == "instance-a1" and harness.output.action_result == "error")
assert(harness.output.last_action == "" and harness.output.result == nil)
assert(#harness.calls == 1 and not harness.calls[1].started and #harness.helpers == 0)
end
for _, invalid in ipairs({ "", string.rep("a", 65), "a/1", "a 1", {} }) do
harness.reset()
harness.form = { action_id = invalid }
controller.action_status()
assert(harness.output.action_result == "error" and #harness.calls == 0)
end
harness.form = {}
-- A manual action names the active account and carries the revision it read.
-- Rejected method/token checks must run before any read that starts a service,
-- submission, config write, log clear or lifecycle helper.
for _, action in ipairs({ "manual_login", "manual_logout", "switch_campus", "switch_hotspot",
"force_stop", "add_campus", "edit_campus", "delete_campus", "set_default_campus",
"add_hotspot", "edit_hotspot", "delete_hotspot", "set_default_hotspot" }) do
harness.reset()
harness.security_allowed = false
harness.form = { action = action }
controller.action_enqueue()
assert(#harness.calls == 0 and #harness.helpers == 0, action .. " bypassed security rejection")
end
for _, handler in ipairs({controller.action_log_clear, controller.action_user_presets_set}) do
harness.reset()
harness.env = { REQUEST_METHOD = "POST" }
harness.form = { data = "presets" }
harness.parsed = { presets = { presets = {}, operators = {} } }
handler()
assert(#harness.calls == 0 and #harness.helpers == 0, "write endpoint bypassed security rejection")
end
harness.security_allowed = true
harness.reset()
harness.responses["action.submit"] = { action_id = "a2", state = "queued" }
harness.form = { action = "manual_login" }
controller.action_enqueue()
local submit = assert(harness.last_call("action.submit"))
assert(submit.params.kind == "manual_login", submit.params.kind)
assert(submit.params.account_id == "c1", submit.params.account_id)
assert(submit.params.expected_revision == 11, submit.params.expected_revision)
assert(submit.params.idempotency_key:find("luci-manual_login-", 1, true) == 1,
submit.params.idempotency_key)
assert(harness.output.ok == true, harness.output.message)
assert(harness.output.requested_at == harness.now, harness.output.requested_at)
assert(harness.output.message == "已提交手动登录请求", harness.output.message)
assert(#harness.writes == 0, "no file may be written")
assert(#harness.commands == 0, "no shell command may run")
-- Two clicks in the same second are one action, not two logins.
local first = submit.params.idempotency_key
harness.reset()
controller.action_enqueue()
assert(harness.last_call("action.submit").params.idempotency_key == first)
-- Switching back to campus uses the default account; switching to a hotspot
-- names the hotspot and no account at all.
harness.reset()
harness.form = { action = "switch_campus" }
controller.action_enqueue()
assert(harness.last_call("action.submit").params.account_id == "c2")
harness.reset()
harness.form = { action = "switch_hotspot" }
controller.action_enqueue()
local hotspot = harness.last_call("action.submit").params
assert(hotspot.hotspot_id == "h1", tostring(hotspot.hotspot_id))
assert(hotspot.account_id == nil, "a hotspot switch does not name a campus account")
-- Background work is lower priority in the Go coordinator. A status preflight
-- must not prevent the user's request from ever reaching that scheduler.
for _, background in ipairs({ "maintain", "presets_refresh", "forced_logout", "quiet_hotspot", "quiet_campus" }) do
for _, state in ipairs({ "queued", "running" }) do
for _, manual in ipairs({ "manual_login", "manual_logout", "switch_campus", "switch_hotspot" }) do
harness.reset()
harness.responses["status.get"] = { actions = { { kind = background, state = state } } }
harness.form = { action = manual }
controller.action_enqueue()
assert(harness.output.ok == true, background .. ": " .. tostring(harness.output.message))
assert(harness.last_call("action.submit").params.kind == manual)
end
end
end
-- An action already in flight is refused with the frozen wording, and nothing
-- is submitted behind it.
harness.reset()
harness.responses["status.get"] = {
service = "running", enabled = true, written_at = "2026-09-18T12:00:05Z", accounts = {},
actions = { { id = "a9", kind = "switch_campus", state = "running" } },
}
harness.form = { action = "manual_logout" }
controller.action_enqueue()
assert(harness.output.ok == false, "a second action must be refused")
assert(harness.output.message:find("已有动作正在执行", 1, true), harness.output.message)
assert(harness.output.pending_action == "switch_campus", harness.output.pending_action)
assert(harness.last_call("action.submit") == nil, "nothing may be submitted")
-- A wizard or an unknown kind remains a conflicting operation, including when
-- it follows background work in the snapshot. Only known background kinds skip
-- the preflight; the daemon can still reject a full/frozen queue itself.
for _, kind in ipairs({ "manual_login", "relogin", "detect_verify", "wifi_setup_start", "future_action" }) do
harness.reset()
harness.responses["status.get"] = { actions = {
{ kind = "maintain", state = "running" },
{ kind = kind, state = "queued" },
} }
controller.action_enqueue()
assert(harness.output.ok == false)
assert(harness.output.pending_action == kind)
assert(harness.last_call("action.submit") == nil)
end
harness.reset()
harness.responses["status.get"] = { actions = { { kind = "maintain", state = "running" } } }
harness.responses["action.submit"] = { error = { code = "Busy", message = "队列已满" } }
controller.action_enqueue()
assert(harness.output.ok == false and harness.output.message == "队列已满")
harness.responses["action.submit"] = { action_id = "a2", state = "queued" }
harness.responses["status.get"] = RUNNING
-- Force stop is the fixed lifecycle helper. No init script, no process hunt,
-- and the user's automatic-authentication switch is not touched.
harness.reset()
harness.form = { action = "force_stop" }
controller.action_enqueue()
assert(harness.helpers[1] == "service stop", tostring(harness.helpers[1]))
assert(harness.output.ok == true, harness.output.message)
assert(harness.output.message == "已强制关闭插件并停止服务", harness.output.message)
assert(harness.last_call("config.apply") == nil, "force stop must not write configuration")
assert(#harness.commands == 0, "force stop must not run a shell command")
harness.reset()
harness.stop_failure = { code = "Internal", message = "停不下来" }
controller.action_enqueue()
assert(harness.output.ok == false and harness.output.message == "停不下来", harness.output.message)
harness.stop_failure = nil
-- Editing an account: the legacy alias is read, only the canonical field is
-- written, and an empty password box keeps the stored credential.
harness.reset()
harness.responses["campus.upsert"] = { config_revision = 12, id = "c1" }
harness.form = {
action = "edit_campus", id = "c1", expected_revision = "11", access_mode = "wired", wired_iface = " ",
network_interface = " wan.test ", auth_enabled = "1", password = "",
user_id = "student", operator_suffix = "telecom", double_stack = "",
}
controller.action_enqueue()
local account = harness.last_call("campus.upsert").params.account
assert(account.wired_iface == "wan.test", account.wired_iface)
assert(account.network_interface == nil, "the alias must not be written back")
assert(account.password == nil, "an empty password box must not clear the credential")
assert(account.auth_enabled == true)
assert(account.login.double_stack == harness.rpc.NULL, "an empty override clears it explicitly")
assert(account.label == "student@telecom", account.label)
assert(harness.output.message == "已更新", harness.output.message)
-- A new account submits what it was given, including an empty password, and
-- lets the daemon choose the identifier.
harness.reset()
harness.responses["campus.upsert"] = { config_revision = 13, id = "c3" }
harness.form = { action = "add_campus", expected_revision = "11", access_mode = "wired", wired_iface = "wan",
user_id = "fresh", password = "", operator_suffix = "" }
controller.action_enqueue()
local created = harness.last_call("campus.upsert").params.account
assert(created.id == nil, "a new account must not choose its own id")
assert(created.password == "", "a new account stores what the form gave it")
assert(created.label == "fresh", created.label)
assert(harness.output.message == "已添加", harness.output.message)
-- A base URL typed as a bare host still reaches the daemon as a URL.
harness.reset()
harness.form = { action = "add_campus", expected_revision = "11", access_mode = "wired", wired_iface = "wan",
user_id = "fresh", base_url = "10.0.0.55/srun_portal" }
controller.action_enqueue()
assert(harness.last_call("campus.upsert").params.account.base_url == "http://10.0.0.55",
harness.last_call("campus.upsert").params.account.base_url)
-- Deleting and defaulting are their own operations, each with the revision.
for _, case in ipairs({
{ action = "delete_campus", method = "campus.remove", message = "已删除" },
{ action = "delete_hotspot", method = "hotspot.remove", message = "已删除" },
{ action = "set_default_campus", method = "campus.set_default", message = "已设为当前默认账号" },
{ action = "set_default_hotspot", method = "hotspot.set_default", message = "已设为当前默认热点" },
}) do
harness.reset()
harness.responses[case.method] = { config_revision = 14 }
harness.form = { action = case.action, id = "c1", expected_revision = "11" }
controller.action_enqueue()
local call = assert(harness.last_call(case.method), case.method .. " was not called")
assert(call.params.id == "c1" and call.params.expected_revision == 11)
assert(harness.output.message == case.message, harness.output.message)
end
-- A configuration conflict is reported, not hidden behind a success.
harness.reset()
harness.responses["campus.remove"] = {
error = { code = "Conflict", message = "配置已变化,请重新读取后再保存" },
}
harness.form = { action = "delete_campus", id = "c1", expected_revision = "11" }
controller.action_enqueue()
assert(harness.output.ok == false, "a conflict must not report success")
assert(harness.output.message == "配置已变化,请重新读取后再保存", harness.output.message)
-- A temporary Wi-Fi account goes through the owned wizard transaction. A
-- missing/rejected job must not fall back to an ordinary account save.
package.preload["luci.dispatcher"] = function()
return { context = { authsession = "browser-session" }, test_post_security = function() return true end }
end
harness.reset()
harness.responses["setup_wifi.account"] = { error = { code = "NotFound", message = "没有当前会话的无线任务" } }
harness.form = { action = "add_campus", expected_revision = "11", setup_job = string.rep("a", 32), access_mode = "wifi" }
controller.action_enqueue()
assert(harness.output.ok == false, "a wizard save must not pretend to work")
assert(harness.last_call("campus.upsert") == nil)
-- Log translation is unchanged: raw gateway codes never reach the user.
for _, code in ipairs({ "no_response_data_error", "not_online_error", "portal_intercept_error",
"auth_html_response_error", "auth_response_parse_error" }) do
local line = controller.friendly_line(
"[2026-06-01 22:00:00] WARN srun_login_response error_code=" .. code)
assert(not line:find(code, 1, true), line)
end
local multi = controller.friendly_line(
"[2026-06-01 22:00:00] WARN multi_wan_session account_id=c1 wired_iface=wan.test | offline")
assert(not multi:find("multi_wan_session", 1, true), multi)
assert(multi:find("wan.test", 1, true), multi)
assert(PORTAL_URL ~= nil)
print("controller actions probe: ok")