"""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