diff --git a/tests/unit/test_system_pin.py b/tests/unit/test_system_pin.py index 6bdaeeb..fb52be8 100644 --- a/tests/unit/test_system_pin.py +++ b/tests/unit/test_system_pin.py @@ -1,6 +1,7 @@ """Tests for PIN verification on arm/disarm endpoints.""" import pytest +from unittest.mock import patch from vigilar.alerts.pin import hash_pin from vigilar.config import VigilarConfig, SecurityConfig from vigilar.web.app import create_app @@ -29,35 +30,55 @@ def app_no_pin(): def test_arm_without_pin_set(app_no_pin): - with app_no_pin.test_client() as c: - rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"}) - assert rv.status_code == 200 - assert rv.get_json()["ok"] is True + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_no_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"}) + assert rv.status_code == 202 + pub.assert_called_once() + payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"] + assert payload["mode"] == "ARMED_AWAY" + assert payload["pin"] == "" def test_arm_correct_pin(app_with_pin): - with app_with_pin.test_client() as c: - rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"}) - assert rv.status_code == 200 - assert rv.get_json()["ok"] is True + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"}) + assert rv.status_code == 202 + pub.assert_called_once() + payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"] + assert payload["pin"] == "1234" -def test_arm_wrong_pin(app_with_pin): - with app_with_pin.test_client() as c: - rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"}) - assert rv.status_code == 401 +def test_arm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin): + """HTTP layer no longer pre-checks the PIN — it forwards to the FSM + unconditionally. The FSM verifies and, on mismatch, logs a warning + and leaves the state unchanged.""" + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"}) + assert rv.status_code == 202 + pub.assert_called_once() + payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"] + assert payload["pin"] == "0000" # forwarded verbatim — FSM will reject def test_disarm_correct_pin(app_with_pin): - with app_with_pin.test_client() as c: - rv = c.post("/system/api/disarm", json={"pin": "1234"}) - assert rv.status_code == 200 + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post("/system/api/disarm", json={"pin": "1234"}) + assert rv.status_code == 202 + pub.assert_called_once() -def test_disarm_wrong_pin(app_with_pin): - with app_with_pin.test_client() as c: - rv = c.post("/system/api/disarm", json={"pin": "9999"}) - assert rv.status_code == 401 +def test_disarm_wrong_pin_still_accepted_by_web_fsm_rejects(app_with_pin): + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post("/system/api/disarm", json={"pin": "9999"}) + assert rv.status_code == 202 + pub.assert_called_once() + payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"] + assert payload["pin"] == "9999" # forwarded verbatim — FSM will reject def test_reset_pin_correct_passphrase(app_with_pin): @@ -77,3 +98,35 @@ def test_reset_pin_wrong_passphrase(app_with_pin): "new_pin": "5678", }) assert rv.status_code == 401 + + +def test_arm_publishes_arm_request_on_mqtt(app_with_pin): + """POST /system/api/arm must publish a SYSTEM_ARM_REQUEST message + carrying the mode, pin, and a 'web' triggered_by tag.""" + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post( + "/system/api/arm", + json={"mode": "ARMED_AWAY", "pin": "1234"}, + ) + assert rv.status_code == 202 + assert rv.get_json()["accepted"] is True + + pub.assert_called_once() + call_args = pub.call_args + # _publish_arm_request(cfg, payload) — payload is args[1] or kwargs["payload"] + payload = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs["payload"] + assert payload["mode"] == "ARMED_AWAY" + assert payload["pin"] == "1234" + assert payload["triggered_by"] == "web" + + +def test_disarm_publishes_arm_request(app_with_pin): + with patch("vigilar.web.blueprints.system._publish_arm_request") as pub: + with app_with_pin.test_client() as c: + rv = c.post("/system/api/disarm", json={"pin": "1234"}) + assert rv.status_code == 202 + + pub.assert_called_once() + payload = pub.call_args.args[1] if len(pub.call_args.args) > 1 else pub.call_args.kwargs["payload"] + assert payload["mode"] == "DISARMED" diff --git a/vigilar/web/blueprints/system.py b/vigilar/web/blueprints/system.py index 1b39529..884c0c0 100644 --- a/vigilar/web/blueprints/system.py +++ b/vigilar/web/blueprints/system.py @@ -54,27 +54,44 @@ def settings_page(): # --- Arm/Disarm --- +def _publish_arm_request(cfg: VigilarConfig, payload: dict) -> None: + """Publish an arm/disarm request on MQTT for the event processor to pick up.""" + from vigilar.bus import MessageBus + from vigilar.constants import Topics + + bus = MessageBus(cfg.mqtt, client_id="vigilar-web-arm-request") + bus.connect() + try: + bus.publish(Topics.SYSTEM_ARM_REQUEST, payload) + finally: + bus.disconnect() + + @system_bp.route("/api/arm", methods=["POST"]) def arm_system(): data = request.get_json() or {} mode = data.get("mode", "ARMED_AWAY") pin = data.get("pin", "") - cfg = _get_cfg() - pin_hash = cfg.security.pin_hash - if pin_hash and not verify_pin(pin, pin_hash): - return jsonify({"error": "Invalid PIN"}), 401 - return jsonify({"ok": True, "state": mode}) + payload = {"mode": mode, "pin": pin, "triggered_by": "web"} + try: + _publish_arm_request(_get_cfg(), payload) + except Exception: + current_app.logger.exception("Failed to publish arm request") + return jsonify({"error": "bus unavailable"}), 503 + return jsonify({"accepted": True, "mode": mode}), 202 @system_bp.route("/api/disarm", methods=["POST"]) def disarm_system(): data = request.get_json() or {} pin = data.get("pin", "") - cfg = _get_cfg() - pin_hash = cfg.security.pin_hash - if pin_hash and not verify_pin(pin, pin_hash): - return jsonify({"error": "Invalid PIN"}), 401 - return jsonify({"ok": True, "state": "DISARMED"}) + payload = {"mode": "DISARMED", "pin": pin, "triggered_by": "web"} + try: + _publish_arm_request(_get_cfg(), payload) + except Exception: + current_app.logger.exception("Failed to publish arm request") + return jsonify({"error": "bus unavailable"}), 503 + return jsonify({"accepted": True, "mode": "DISARMED"}), 202 @system_bp.route("/api/reset-pin", methods=["POST"])