mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
271 lines
14 KiB
Python
271 lines
14 KiB
Python
"""Execute AP policy editing and status rendering through shipped LuCI code."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
JS = ROOT / "root/www/luci-static/resources/smart_srun.js"
|
|
LUA = ROOT / "root/usr/lib/lua/luci"
|
|
BSSID = "02:11:22:33:44:55"
|
|
|
|
|
|
class APSelectionUITests(unittest.TestCase):
|
|
def run_js(self, scenario):
|
|
node = shutil.which("node")
|
|
if not node:
|
|
self.skipTest("node is not installed")
|
|
script = r"""
|
|
const fs = require('fs'), vm = require('vm');
|
|
const scenario = JSON.parse(process.argv[1]);
|
|
let source = fs.readFileSync(process.argv[2], 'utf8');
|
|
const end = source.lastIndexOf('})();');
|
|
source = source.slice(0, end) + `
|
|
window.apTest = { normalize: normalizeApSelection, update: updateCampusAccessModeUI,
|
|
overview: initOverview, edit: function() { modalType = 'campus'; modalEditId = 'one'; } };
|
|
` + source.slice(end);
|
|
const nodes = {}, sent = [], alerts = [];
|
|
nodes['smart-srun-config-revision'] = {value: '17'};
|
|
['label', 'user_id', 'operator_suffix', 'access_mode', 'wired_iface', 'auth_enabled',
|
|
'password', 'base_url', 'ac_id', 'login-n', 'login-type', 'login-enc', 'info-prefix',
|
|
'double-stack', 'login-os', 'login-name', 'ssid', 'bssid', 'radio', 'ap_selection'].forEach(key => {
|
|
nodes['jm-' + key] = {value: '', style: {}};
|
|
});
|
|
['wired-iface', 'auth-enabled', 'ssid', 'bssid', 'radio', 'ap-selection'].forEach(key => {
|
|
nodes['jm-' + key + '-row'] = {style: {}};
|
|
});
|
|
['smart-srun-overview', 'smart-srun-overview-title', 'smart-srun-overview-meta',
|
|
'smart-srun-overview-details-content', 'smart-srun-overview-pending'].forEach(key => {
|
|
nodes[key] = {style: {}};
|
|
});
|
|
nodes['jm-access_mode'].value = scenario.mode || 'wifi';
|
|
nodes['jm-ap_selection'].value = scenario.policy || 'auto';
|
|
nodes['jm-bssid'].value = scenario.bssid || '';
|
|
function FormData() { this.fields = {}; }
|
|
FormData.prototype.append = function(key, value) { this.fields[key] = value; };
|
|
function XHR() {}
|
|
XHR.prototype.open = function(method, url) { this.url = url; this.method = method; };
|
|
XHR.prototype.send = function(body) {
|
|
if (this.method === 'POST') { sent.push(body.fields); return; }
|
|
this.readyState = 4; this.status = 200;
|
|
this.responseText = JSON.stringify(Object.assign({status: '未知'}, scenario.status));
|
|
this.onreadystatechange();
|
|
};
|
|
const context = { window: {setInterval() {}}, document: {
|
|
readyState: 'loading', addEventListener() {},
|
|
querySelector(selector) { return selector === 'input[name="token"]' ? {value: 'test-csrf'} : null; },
|
|
getElementById(id) { return nodes[id] || null; }
|
|
}, XMLHttpRequest: XHR, FormData, alert(value) { alerts.push(value); }, Date, JSON };
|
|
vm.runInNewContext(source, context);
|
|
context.window.apTest.edit();
|
|
context.window.apTest.update();
|
|
context.window.smartModalSave();
|
|
context.window.apTest.overview();
|
|
console.log(JSON.stringify({ sent, alerts, bssidDisabled: nodes['jm-bssid'].disabled,
|
|
policyDisabled: nodes['jm-ap_selection'].disabled,
|
|
normalizations: [context.window.apTest.normalize(null, scenario.bssid),
|
|
context.window.apTest.normalize('auto', scenario.bssid)],
|
|
overview: nodes['smart-srun-overview-details-content'].innerHTML,
|
|
summary: nodes['smart-srun-overview-meta'].innerHTML,
|
|
title: nodes['smart-srun-overview-title'].textContent,
|
|
pending: nodes['smart-srun-overview-pending'].textContent }));
|
|
"""
|
|
output = subprocess.run(
|
|
[node, "-e", script, json.dumps(scenario), str(JS)],
|
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", timeout=15,
|
|
)
|
|
self.assertEqual(output.returncode, 0, output.stderr)
|
|
result = json.loads(output.stdout)
|
|
result["fields"] = dict(re.findall(r"<dt>(.*?)</dt><dd[^>]*>(.*?)</dd>", result["overview"]))
|
|
return result
|
|
|
|
def test_auto_and_strongest_disable_fixed_field_but_preserve_remembered_value(self):
|
|
for policy in ("auto", "strongest"):
|
|
with self.subTest(policy=policy):
|
|
output = self.run_js({"policy": policy, "bssid": BSSID})
|
|
self.assertTrue(output["bssidDisabled"])
|
|
self.assertFalse(output["policyDisabled"])
|
|
self.assertEqual(output["sent"][0]["ap_selection"], policy)
|
|
self.assertEqual(output["sent"][0]["bssid"], BSSID)
|
|
self.assertEqual(output["normalizations"], ["fixed", "auto"])
|
|
|
|
def test_fixed_invalid_does_not_send_or_lock_save_and_wired_ignores_it(self):
|
|
for address in ("", "bad", "ff:ff:ff:ff:ff:ff", "01:11:22:33:44:55"):
|
|
with self.subTest(address=address):
|
|
output = self.run_js({"policy": "fixed", "bssid": address})
|
|
self.assertFalse(output["bssidDisabled"])
|
|
self.assertEqual(output["sent"], [])
|
|
self.assertIn("固定 BSSID", output["alerts"][0])
|
|
valid = self.run_js({"policy": "fixed", "bssid": BSSID})
|
|
self.assertEqual(valid["alerts"], [])
|
|
self.assertEqual(valid["sent"][0]["bssid"], BSSID)
|
|
wired = self.run_js({"mode": "wired", "policy": "fixed"})
|
|
self.assertTrue(wired["bssidDisabled"])
|
|
self.assertTrue(wired["policyDisabled"])
|
|
self.assertEqual(len(wired["sent"]), 1)
|
|
|
|
def test_status_uses_observed_values_escapes_reason_and_keeps_unknown_signal(self):
|
|
output = self.run_js({"status": {
|
|
"current_bssid": BSSID, "current_wireless_ifname": "phy0-sta0",
|
|
"current_signal": -47, "current_channel": 36,
|
|
"ap_selection_policy": "strongest", "ap_selection_reason": "<untrusted>",
|
|
}})
|
|
self.assertIn("-47 dBm", output["overview"])
|
|
self.assertEqual(output["fields"]["实际 AP"], BSSID)
|
|
self.assertEqual(output["fields"]["信道"], "36")
|
|
self.assertIn("<untrusted>", output["overview"])
|
|
missing = self.run_js({"status": {"campus_bssid": BSSID, "current_signal": None}})
|
|
self.assertEqual(missing["fields"]["实际 AP"], "未知")
|
|
self.assertEqual(missing["fields"]["信号"], "未知")
|
|
self.assertNotIn(BSSID, missing["overview"])
|
|
self.assertNotIn("0 dBm", missing["overview"])
|
|
|
|
def test_wired_hides_ap_fields_and_hotspot_only_shows_observations(self):
|
|
wired = self.run_js({"status": {"current_campus_access_mode": "wired"}})
|
|
self.assertNotIn("实际 AP", wired["fields"])
|
|
self.assertNotIn("信号", wired["fields"])
|
|
hotspot = self.run_js({"status": {"mode": "hotspot", "current_bssid": BSSID, "current_signal": -55}})
|
|
self.assertEqual(hotspot["fields"]["实际 AP"], BSSID)
|
|
self.assertIn("-55 dBm", hotspot["overview"])
|
|
self.assertNotIn("AP 选择", hotspot["fields"])
|
|
self.assertNotIn("选择说明", hotspot["fields"])
|
|
|
|
def test_overview_summary_hides_diagnostics_but_keeps_actions_and_errors_visible(self):
|
|
output = self.run_js({"status": {
|
|
"status": "在线,下一次检测间隔 45 秒", "current_ssid": "Campus<WiFi>",
|
|
"current_ip": "192.0.2.4", "online_account_label": "student", "current_signal": -47,
|
|
"current_bssid": BSSID, "pending_action": "login",
|
|
}})
|
|
self.assertEqual(output["title"], "在线")
|
|
self.assertIn("Campus<WiFi>", output["summary"])
|
|
self.assertIn("student", output["summary"])
|
|
for technical in (BSSID, "192.0.2.4", "-47", "45 秒"):
|
|
self.assertNotIn(technical, output["summary"])
|
|
self.assertIn(technical, output["overview"])
|
|
self.assertIn("login", output["pending"])
|
|
failure = self.run_js({"status": {"status": "认证失败:密码错误"}})
|
|
self.assertEqual(failure["title"], "认证失败:密码错误")
|
|
wired = self.run_js({"status": {"current_campus_access_mode": "wired", "current_iface": "wan"}})
|
|
self.assertIn("有线网络", wired["summary"])
|
|
self.assertIn("wan", wired["summary"])
|
|
self.assertNotIn("Wi-Fi", wired["summary"])
|
|
|
|
def test_advanced_fields_and_ssr_use_policy_and_observed_state(self):
|
|
source = JS.read_text(encoding="utf-8")
|
|
advanced = source.split('<summary>进阶设置</summary>', 1)[1].split("'</details>'", 1)[0]
|
|
for field in ("jm-ap_selection", "jm-radio", "jm-bssid"):
|
|
self.assertIn('id="' + field + '"', advanced)
|
|
cbi = (LUA / "model/cbi/smart_srun.lua").read_text(encoding="utf-8")
|
|
overview = cbi.split("function overview_status.cfgvalue()", 1)[1].split("\nend", 1)[0]
|
|
for field in ("current_bssid", "current_signal", "current_channel", "current_wireless_ifname"):
|
|
self.assertIn("state." + field, overview)
|
|
self.assertNotIn("campus_bssid", overview)
|
|
self.assertIn('<details id="smart-srun-overview-details">', overview)
|
|
self.assertNotIn('<details id="smart-srun-overview-details" open', overview)
|
|
self.assertIn('ap_selection == "fixed"', cbi)
|
|
|
|
def test_real_lua_controller_relays_policy_and_the_daemons_refusal(self):
|
|
"""The policy reaches the daemon intact and a refusal reaches the user.
|
|
|
|
The Lua no longer validates the address itself: spec 02 puts business
|
|
validation in Go, the frozen dialog keeps its client-side hint, and
|
|
this asserts the third part -- that the page relays both the value and
|
|
the refusal rather than deciding either. (M00 ledger: ported_behavior
|
|
-> T27;T39;T51.)
|
|
"""
|
|
lua = shutil.which("lua")
|
|
if not lua:
|
|
self.skipTest("lua is not installed")
|
|
result = subprocess.run(
|
|
[lua, (ROOT / "tests/lua/controller_ap_selection.lua").as_posix(),
|
|
ROOT.as_posix()],
|
|
cwd=str(ROOT), stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, encoding="utf-8", timeout=15,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
|
|
|
|
|
|
def test_account_table_only_displays_remembered_bssid_for_fixed_policy(self):
|
|
lua = shutil.which("lua")
|
|
if not lua:
|
|
self.skipTest("lua is not installed")
|
|
script = r"""
|
|
local fs = {readfile=function() return nil end}
|
|
local jsonc = {parse=function() return nil end, stringify=function() return "[]" end}
|
|
package.preload["nixio.fs"] = function() return fs end
|
|
package.preload["luci.jsonc"] = function() return jsonc end
|
|
package.preload["nixio"] = function() return {} end
|
|
package.preload["nixio.util"] = function() return {} end
|
|
local schema = dofile(SCHEMA_PATH)
|
|
local util = {pcdata=function(value) return tostring(value or "") end,
|
|
trim=function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end}
|
|
local function load_state() return {current_mode="campus", current_campus_access_mode="wifi",
|
|
current_ssid="Campus", current_bssid="02:66:77:88:99:aa", online_account_label="student"} end
|
|
local handle = assert(io.open(CBI_PATH, "rb"))
|
|
local source = handle:read("*a"):gsub("\r\n", "\n")
|
|
handle:close()
|
|
local body = assert(source:match("function tables_html%.cfgvalue%(%)\n(.-)\nend"))
|
|
local render = assert(loadstring("return function() " .. body .. " end"))()
|
|
-- The user-preset store now comes from the daemon; a page that cannot reach
|
|
-- it still has to render the account table.
|
|
local rpc = {call=function() return nil end}
|
|
local context = setmetatable({cfg={}, RADIO_CHOICES={}, school_presets={},
|
|
schema=schema, util=util, fs=fs, jsonc=jsonc, rpc=rpc, load_state=load_state}, {__index=_G})
|
|
setfenv(render, context)
|
|
for _, case in ipairs({
|
|
{policy="auto"}, {policy="strongest"}, {policy="fixed", fixed=true},
|
|
{fixed=true}, {policy="fixed", access="wired"}
|
|
}) do
|
|
context.cfg = {campus_accounts={{id="one", user_id="student", ssid="Campus",
|
|
bssid="02:11:22:33:44:55", ap_selection=case.policy, access_mode=case.access or "wifi"}}}
|
|
local rows = render():match("<tbody>(.-)</tbody>")
|
|
local label = case.access == "wired" and string.char(226, 128, 148)
|
|
or schema.ap_selection_label(case.policy or "fixed")
|
|
assert(rows:find(label, 1, true), rows)
|
|
assert((rows:find("02:11:22:33:44:55", 1, true) ~= nil) == (case.fixed == true), rows)
|
|
end
|
|
""".replace("SCHEMA_PATH", json.dumps((LUA / "smart_srun/schema.lua").as_posix())).replace(
|
|
"CBI_PATH", json.dumps((LUA / "model/cbi/smart_srun.lua").as_posix())
|
|
)
|
|
subprocess.run([lua, "-e", script], check=True, stdin=subprocess.DEVNULL,
|
|
capture_output=True, text=True, encoding="utf-8", timeout=15)
|
|
|
|
def test_asset_url_tracks_js_mtime_and_size_with_unavailable_stat_fallback(self):
|
|
lua = shutil.which("lua")
|
|
if not lua:
|
|
self.skipTest("lua is not installed")
|
|
script = r"""
|
|
local handle = assert(io.open(CBI_PATH, "rb"))
|
|
local source = handle:read("*a"):gsub("\r\n", "\n")
|
|
handle:close()
|
|
local body = assert(source:match("local function render_js_asset_tag%(%)\n(.-)\nend"))
|
|
local render = assert(loadstring("return function() " .. body .. " end"))()
|
|
local path = "/luci-static/resources/smart_srun.js"
|
|
local current
|
|
local fs = {stat=function(actual) assert(actual == "/www" .. path); return current end}
|
|
local context = setmetatable({JS_ASSET_PATH=path, fs=fs,
|
|
util={pcdata=function(value) return value end}}, {__index=_G})
|
|
setfenv(render, context)
|
|
for _, case in ipairs({
|
|
{stat={mtime=0, size=72217}, query="?v=0-72217"},
|
|
{stat={mtime=0, size=72218}, query="?v=0-72218"},
|
|
{stat={mtime=123, size=72218}, query="?v=123-72218"},
|
|
{query=""}, {stat={mtime=0}, query=""}, {stat={mtime="invalid", size=10}, query=""}
|
|
}) do
|
|
current = case.stat
|
|
assert(render() == '<script src="' .. path .. case.query .. '"></script>')
|
|
end
|
|
fs.stat = nil
|
|
assert(render() == '<script src="' .. path .. '"></script>')
|
|
""".replace("CBI_PATH", json.dumps((LUA / "model/cbi/smart_srun.lua").as_posix()))
|
|
subprocess.run([lua, "-e", script], check=True, stdin=subprocess.DEVNULL,
|
|
capture_output=True, text=True, encoding="utf-8", timeout=15)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|