451 lines
20 KiB
Python
451 lines
20 KiB
Python
"""Web UI API tests (spec §10): auth, node layout, values, manipulation,
|
|
diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from wago_sim.webui.app import build_webui
|
|
|
|
|
|
@pytest.fixture
|
|
def webui(app):
|
|
"""FastAPI app bound to the simulator app (default credentials)."""
|
|
return build_webui(app, user="admin", password="simu")
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(webui):
|
|
"""Async httpx client with a valid session (default credentials)."""
|
|
transport = httpx.ASGITransport(app=webui)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
r = await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["ok"] is True
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
async def anon_client(webui):
|
|
"""Async httpx client WITHOUT login."""
|
|
transport = httpx.ASGITransport(app=webui)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
class TestAuth:
|
|
async def test_login_page_served(self, anon_client):
|
|
r = await anon_client.get("/login")
|
|
assert r.status_code == 200
|
|
assert "WAGO" in r.text
|
|
|
|
async def test_root_redirects_when_anonymous(self, anon_client):
|
|
r = await anon_client.get("/", follow_redirects=False)
|
|
assert r.status_code == 303
|
|
assert r.headers["location"] == "/login"
|
|
|
|
async def test_api_requires_session(self, anon_client):
|
|
r = await anon_client.get("/api/node")
|
|
assert r.status_code == 401
|
|
|
|
async def test_login_wrong_password(self, anon_client):
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "wrong"})
|
|
assert r.status_code == 401
|
|
assert r.json()["ok"] is False
|
|
|
|
async def test_logout_invalidates_session(self, client):
|
|
r = await client.post("/api/auth/logout")
|
|
assert r.status_code == 200
|
|
r = await client.get("/api/node")
|
|
assert r.status_code == 401
|
|
|
|
async def test_login_lockout_after_failures(self, anon_client, monkeypatch):
|
|
import wago_sim.webui.auth as auth_mod
|
|
|
|
# reset the module-level throttle so suite order does not matter
|
|
monkeypatch.setattr(auth_mod, "_LOGIN_ATTEMPTS", {})
|
|
for _ in range(5):
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "nope"})
|
|
assert r.status_code == 401
|
|
# 6th attempt with CORRECT password is locked out too
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
class TestNodeAPI:
|
|
async def test_node_layout(self, client):
|
|
r = await client.get("/api/node")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["coupler"] == "750-362"
|
|
articles = [m["article"] for m in data["modules"]]
|
|
assert "750-430" in articles # DI_8 from pump_modules
|
|
# module-based addressing: channel 0 -> address like DI0.0
|
|
di_mod = next(m for m in data["modules"] if m["article"] == "750-430")
|
|
assert di_mod["channels"][0]["address"] == "DI0.0"
|
|
assert "modbus" in di_mod["channels"][0]
|
|
# image sizes from the layout are reported
|
|
assert data["image_bits"]["digital_in"] == 24 # DI_8 + DI_16
|
|
|
|
async def test_values_snapshot(self, client):
|
|
r = await client.get("/api/values")
|
|
assert r.status_code == 200
|
|
values = r.json()["values"]
|
|
kinds = {v["kind"] for v in values}
|
|
assert kinds == {"di", "do", "ai", "ao"}
|
|
|
|
|
|
class TestManipulation:
|
|
async def test_toggle_di_via_api(self, client, datastore):
|
|
r = await client.post("/api/channels/di/0/0", json={"action": "toggle"})
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] is True
|
|
# the internal memory handler shows the same state (channel 1)
|
|
assert datastore.digital_input_channel(1) is True
|
|
|
|
async def test_toggle_do_forces_output_image(self, client, datastore):
|
|
# DO_8 sits at module slot 2 in the pump fixture
|
|
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
|
assert r.status_code == 200
|
|
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
|
assert datastore.digital_output_channel(1) is True
|
|
# ... and the +0x0200 read-back mirror reflects it
|
|
bits = datastore.read_bits(0x0200, 1)
|
|
assert bits == [True]
|
|
|
|
async def test_ai_value_set(self, client, datastore):
|
|
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
|
|
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0
|
|
r = await client.post("/api/channels/ai/4/0", json={"value": 200})
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 200
|
|
assert datastore.get_input_word(0) == 200
|
|
|
|
async def test_ao_force_writes_output_image(self, client, datastore):
|
|
r = await client.post("/api/channels/ao/5/0", json={"value": 1234})
|
|
assert r.status_code == 200
|
|
# written at the output image word 0 -> read back at +0x0200
|
|
assert datastore.read_words(0x0200, 1) == [1234]
|
|
|
|
async def test_unknown_channel_404(self, client):
|
|
r = await client.post("/api/channels/di/99/0", json={"action": "toggle"})
|
|
assert r.status_code == 404
|
|
|
|
async def test_kind_mismatch_400(self, client):
|
|
# DI_16 module (index 1) has no DO channels
|
|
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
class TestDiagnostics:
|
|
async def test_diagnostics_payload(self, client):
|
|
r = await client.get("/api/diagnostics")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["watchdog"]["status"] in (0, 1, 2)
|
|
assert "mod_tcp_message_counter" in data["statistics"]
|
|
assert "led_error" in data
|
|
|
|
async def test_inject_and_clear_led_fault(self, client, app):
|
|
r = await client.post("/api/diagnostics/inject-fault",
|
|
json={"fault": "led", "code": 5, "argument": 2})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1020, 1) == [5]
|
|
assert app.registers.read_config(0x1021, 1) == [2]
|
|
r = await client.post("/api/diagnostics/clear-fault", json={"fault": "led"})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1020, 1) == [0]
|
|
|
|
async def test_inject_module_diagnosis(self, client, app):
|
|
r = await client.post("/api/diagnostics/inject-fault",
|
|
json={"fault": "module_diagnosis", "module": 2,
|
|
"channel": 1, "diagnosis": 0x0001})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1050, 3) == [2, 1, 0x0001]
|
|
|
|
async def test_reset_stats(self, client, app):
|
|
app.stats.bad_address += 3
|
|
r = await client.post("/api/diagnostics/reset-stats")
|
|
assert r.status_code == 200
|
|
assert app.stats.bad_address == 0
|
|
|
|
|
|
class TestReadOnlyMode:
|
|
async def test_readonly_blocks_writes(self, app):
|
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
|
transport = httpx.ASGITransport(app=ro)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
r = await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 200
|
|
assert r.json()["readonly"] is True
|
|
r = await c.get("/api/node")
|
|
assert r.status_code == 200
|
|
r = await c.post("/api/channels/di/0/0", json={"action": "toggle"})
|
|
assert r.status_code == 403
|
|
assert "read-only" in r.json()["detail"]
|
|
r = await c.post("/api/diagnostics/reset-stats")
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestCouplerConfig:
|
|
async def test_get_coupler_config(self, client, app):
|
|
r = await client.get("/api/config/coupler")
|
|
assert r.status_code == 200
|
|
cfg = r.json()
|
|
# defaults: 100 units = 10 s watchdog, full masks, no delay
|
|
assert cfg["watchdog"]["time_units"] == 100
|
|
assert cfg["watchdog"]["mask_fc_1_16"] == 0xFFFF
|
|
assert cfg["watchdog"]["type"] == 0
|
|
assert cfg["response_delay_ms"] == 0
|
|
assert cfg["idle_timeout_units"] == 600
|
|
assert len(cfg["mac_id"]) == 3
|
|
# image sizes for the pump fixture (AO_2 out / AI_2 in analog,
|
|
# DO 24 bits out / DI 24 bits in — 0x1022..0x1025 per manual §11.2.5.5)
|
|
assert cfg["image_bits"]["analog_in"] == 32
|
|
assert cfg["image_bits"]["analog_out"] == 32
|
|
assert cfg["image_bits"]["digital_in"] == 24
|
|
assert cfg["image_bits"]["digital_out"] == 24
|
|
|
|
async def test_put_coupler_config_roundtrip(self, client, app):
|
|
r = await client.put("/api/config/coupler", json={
|
|
"watchdog": {"time_units": 200, "close_on_timeout": True},
|
|
"response_delay_ms": 25,
|
|
"idle_timeout_units": 10,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["applied"] == 4
|
|
# written through the internal register bank
|
|
assert app.registers.read_config(0x1000, 1) == [200]
|
|
assert app.registers.read_config(0x1037, 1) == [25]
|
|
assert app.registers.read_config(0x1030, 1) == [10]
|
|
assert app.registers.read_config(0x1009, 1) == [1]
|
|
|
|
async def test_watchdog_refusal_while_running(self, client, app):
|
|
# start the watchdog (trigger write, like the manual's flow)
|
|
app.watchdog.write(0x1003, 0x0001)
|
|
assert app.watchdog.status == 1
|
|
r = await client.put("/api/config/coupler", json={
|
|
"watchdog": {"time_units": 50},
|
|
})
|
|
assert r.status_code == 400
|
|
assert "cannot change time while running" in r.json()["detail"]
|
|
|
|
async def test_put_coupler_config_readonly_403(self, app):
|
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
|
transport = httpx.ASGITransport(app=ro)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
r = await c.put("/api/config/coupler", json={"response_delay_ms": 1})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestModuleConfig:
|
|
async def test_get_module_config(self, client, app):
|
|
r = await client.get("/api/config/modules")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert [m["article"] for m in data["modules"]] == [
|
|
"750-430", "750-1405", "750-530", "750-1516", "750-467", "750-554",
|
|
]
|
|
articles = {c["article"] for c in data["catalog"]}
|
|
assert "750-461" in articles # catalog has all 13 articles
|
|
assert data["max_modules"] == 64
|
|
assert data["node_config_path"] is None # fixture built without a path
|
|
|
|
async def test_rebuild_updates_layout_and_registers(self, client, app):
|
|
# swap in a bigger DI module list
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-1405", "750-1405", "750-530"], "persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["image_bits"]["digital_in"] == 32
|
|
# /api/node reflects the new layout
|
|
r = await client.get("/api/node")
|
|
assert [m["article"] for m in r.json()["modules"]] == [
|
|
"750-1405", "750-1405", "750-530",
|
|
]
|
|
# register bank updated: 0x1025 digital-IN bits = 32 (two DI_16)
|
|
assert app.registers.read_config(0x1025, 1) == [32]
|
|
# 0x1024 digital-OUT bits = 8 (one DO_8)
|
|
assert app.registers.read_config(0x1024, 1) == [8]
|
|
# module description 0x2030 contains the new articles
|
|
# (2 chars/word, high byte first — reverse of _string_to_words)
|
|
words = app.registers.read_config(0x2030, 65)
|
|
text = "".join(
|
|
chr(b) for w in words for b in ((w >> 8) & 0xFF, w & 0xFF)
|
|
)
|
|
assert "750-1405" in text
|
|
# values were cleared by the rebuild (KBUS-reset semantics)
|
|
assert all(w == 0 for w in app.datastore.output_words)
|
|
|
|
async def test_rebuild_rejected_invalid(self, client):
|
|
r = await client.put("/api/config/modules", json={"modules": ["750-9999"]})
|
|
assert r.status_code == 400
|
|
assert "750-9999" in r.json()["detail"]
|
|
r = await client.put("/api/config/modules", json={"modules": []})
|
|
assert r.status_code == 400
|
|
r = await client.put("/api/config/modules", json={"modules": ["750-430"] * 65})
|
|
assert r.status_code == 400
|
|
assert "max 64" in r.json()["detail"]
|
|
|
|
async def test_rebuild_persists_yaml(self, client, app, tmp_path):
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-530"], "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["saved_to"] == str(cfg_file)
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert 'coupler: 750-362' in text
|
|
assert '"750-430"' in text and '"750-530"' in text
|
|
# and it loads back through the normal loader
|
|
from wago_sim.scenario.dsl import load_yaml, load_node_modules
|
|
modules = load_node_modules(load_yaml(cfg_file))
|
|
assert [m.article for m in modules] == ["750-430", "750-530"]
|
|
|
|
async def test_modbus_still_serves_after_rebuild(self, client, app):
|
|
"""Modbus connections survive a rebuild; new image answers."""
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-530", "750-554"], "persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# write an output via the internal dispatch and read the mirror
|
|
app.datastore.write_words(0x0000, [0x1234])
|
|
assert app.datastore.read_words(0x0200, 1) == [0x1234]
|
|
|
|
|
|
class TestChannelNames:
|
|
async def test_names_in_layout_and_values(self, client, app):
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button", "AI4.0": "tank_level"},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# layout carries the name on the right channel
|
|
r = await client.get("/api/node")
|
|
di0 = next(c for m in r.json()["modules"] if m["index"] == 0
|
|
for c in m["channels"] if c["address"] == "DI0.0")
|
|
assert di0["channel_name"] == "start_button"
|
|
# values snapshot carries it too
|
|
r = await client.get("/api/values")
|
|
entry = next(v for v in r.json()["values"]
|
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
|
assert entry["name"] == "tank_level"
|
|
|
|
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DO2.0": "pump_relay"}, "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert "channel_names:" in text
|
|
assert "DO2.0: pump_relay" in text
|
|
# and it loads back
|
|
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
|
names = load_channel_names(load_yaml(cfg_file))
|
|
assert names == {"DO2.0": "pump_relay"}
|
|
|
|
async def test_invalid_code_rejected(self, client):
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"XX0.0": "nope"}, "persist": False,
|
|
})
|
|
assert r.status_code == 400
|
|
assert "invalid I/O code" in r.json()["detail"]
|
|
|
|
async def test_names_survive_module_save(self, client, app, tmp_path):
|
|
"""Module SAVE CONFIG must not wipe channel names (they persist)."""
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button"}, "persist": True,
|
|
})
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-530"], "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
|
# YAML contains both sections
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "DI0.0: start_button" in text
|
|
|
|
async def test_readonly_blocks_rename(self, app):
|
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
|
transport = httpx.ASGITransport(app=ro)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
r = await c.put("/api/config/channel-names", json={"channel_names": {}})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestConfigExportImport:
|
|
async def test_export_shape(self, client):
|
|
r = await client.get("/api/config/export")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["format"] == "wago-sim-config"
|
|
assert data["version"] == 1
|
|
assert len(data["modules"]) == 6
|
|
assert "coupler_settings" in data
|
|
assert "channel_names" in data
|
|
|
|
async def test_export_import_roundtrip(self, client, app, tmp_path):
|
|
# configure something recognizable
|
|
await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button"}, "persist": False,
|
|
})
|
|
await client.put("/api/config/coupler", json={"response_delay_ms": 33})
|
|
exported = (await client.get("/api/config/export")).json()
|
|
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
r = await client.post("/api/config/import", json=exported)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert "750-430" in body["modules"]
|
|
# names and coupler settings applied
|
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
|
assert app.registers.read_config(0x1037, 1) == [33]
|
|
# persisted to YAML
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "DI0.0: start_button" in text
|
|
|
|
async def test_import_rejects_bad_payload(self, client, app):
|
|
# wrong format marker
|
|
r = await client.post("/api/config/import", json={"format": "nope"})
|
|
assert r.status_code == 400
|
|
# unknown article: nothing applied
|
|
before = [m.article for m in app.node.modules]
|
|
r = await client.post("/api/config/import", json={
|
|
"format": "wago-sim-config", "modules": ["750-9999"],
|
|
"channel_names": {}, "coupler_settings": {},
|
|
})
|
|
assert r.status_code == 400
|
|
assert [m.article for m in app.node.modules] == before # untouched
|
|
|
|
async def test_import_drops_stale_name_codes(self, client, app):
|
|
# names for channels that don't exist on the imported layout
|
|
r = await client.post("/api/config/import", json={
|
|
"format": "wago-sim-config",
|
|
"modules": ["750-530"],
|
|
"channel_names": {"DO0.0": "pump", "DI0.0": "stale_name"},
|
|
"coupler_settings": {},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["dropped_codes"] == ["DI0.0"]
|
|
assert app.node.channel_names == {"DO0.0": "pump"}
|
|
|
|
async def test_readonly_blocks_import_export(self, app):
|
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
|
transport = httpx.ASGITransport(app=ro)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
r = await c.get("/api/config/export")
|
|
assert r.status_code == 200 # export is a read
|
|
r = await c.post("/api/config/import", json={"format": "wago-sim-config",
|
|
"modules": ["750-430"]})
|
|
assert r.status_code == 403 |