fix: unify PIN hashing across CLI, FSM, and web (closes #2) #7
@@ -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 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 == 200
|
||||
assert rv.get_json()["ok"] is True
|
||||
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 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 == 200
|
||||
assert rv.get_json()["ok"] is True
|
||||
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):
|
||||
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 == 401
|
||||
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 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 == 200
|
||||
assert rv.status_code == 202
|
||||
pub.assert_called_once()
|
||||
|
||||
|
||||
def test_disarm_wrong_pin(app_with_pin):
|
||||
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 == 401
|
||||
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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user