502 lines
15 KiB
Python
502 lines
15 KiB
Python
"""Tests for the HTTP bridge.
|
|
|
|
Two things matter most here:
|
|
|
|
1. The bridge publishes card events without claiming to authenticate anyone.
|
|
2. A page on another origin must not be able to read your taps.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
SRC = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
|
)
|
|
if SRC not in sys.path:
|
|
sys.path.insert(0, SRC)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from rdm_card_reader import bridge # noqa: E402
|
|
from rdm_card_reader.bridge import ( # noqa: E402
|
|
MODE_LOGIN,
|
|
MODE_MONITOR,
|
|
EventHub,
|
|
create_server,
|
|
)
|
|
from rdm_card_reader.card import CardEvent # noqa: E402
|
|
|
|
|
|
def make_event(event="card_inserted", value=12886197):
|
|
return CardEvent(
|
|
event=event,
|
|
timestamp="2026-10-01 12:00:00",
|
|
value=value,
|
|
checksum=222,
|
|
card_type=15,
|
|
is_valid=True,
|
|
port="/dev/ttyUSB0",
|
|
)
|
|
|
|
|
|
# -- hub --------------------------------------------------------------------
|
|
|
|
def test_hub_publishes_to_subscriber():
|
|
hub = EventHub()
|
|
queue = hub.subscribe()
|
|
hub.publish(make_event())
|
|
assert len(queue) == 1
|
|
assert queue[0]["id"] == 12886197
|
|
|
|
|
|
def test_hub_login_mode_hides_removals():
|
|
"""Removal must not look like a second authentication attempt."""
|
|
hub = EventHub(mode=MODE_LOGIN)
|
|
queue = hub.subscribe()
|
|
hub.publish(make_event("card_removed"))
|
|
assert len(queue) == 0
|
|
|
|
|
|
def test_hub_monitor_mode_publishes_removals():
|
|
hub = EventHub(mode=MODE_MONITOR)
|
|
queue = hub.subscribe()
|
|
hub.publish(make_event("card_removed"))
|
|
assert len(queue) == 1
|
|
|
|
|
|
def test_hub_unsubscribe_stops_delivery():
|
|
hub = EventHub()
|
|
queue = hub.subscribe()
|
|
hub.unsubscribe(queue)
|
|
hub.publish(make_event())
|
|
assert len(queue) == 0
|
|
|
|
|
|
def test_hub_tracks_last_tap_only_on_insert():
|
|
hub = EventHub(mode=MODE_MONITOR)
|
|
hub.publish(make_event("card_inserted", value=111))
|
|
hub.publish(make_event("card_removed", value=111))
|
|
assert hub.last()["id"] == 111
|
|
|
|
|
|
def test_hub_history_is_bounded():
|
|
hub = EventHub(history=3)
|
|
for value in range(10):
|
|
hub.publish(make_event(value=value))
|
|
assert len(hub.taps(limit=99)) == 3
|
|
|
|
|
|
def test_hub_health_reports_state():
|
|
hub = EventHub()
|
|
hub.set_port("/dev/ttyUSB0")
|
|
hub.publish(make_event())
|
|
health = hub.health()
|
|
assert health["port"] == "/dev/ttyUSB0"
|
|
assert health["counts"]["card_inserted"] == 1
|
|
assert health["last_tap"]["id"] == 12886197
|
|
assert health["last_tap_age_seconds"] is not None
|
|
|
|
|
|
def test_hub_rejects_unknown_mode():
|
|
hub = EventHub()
|
|
assert hub.set_mode("bogus") is False
|
|
assert hub.mode == MODE_LOGIN
|
|
|
|
|
|
def test_hub_accepts_valid_mode_switch():
|
|
hub = EventHub()
|
|
assert hub.set_mode(MODE_MONITOR) is True
|
|
assert hub.mode == MODE_MONITOR
|
|
|
|
|
|
# -- HTTP -------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def server():
|
|
"""A live bridge on an ephemeral port, torn down after each test."""
|
|
hub = EventHub()
|
|
hub.set_port("/dev/ttyUSB0")
|
|
srv = create_server(hub, host="127.0.0.1", port=0)
|
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield srv, hub
|
|
srv.shutdown()
|
|
srv.server_close()
|
|
|
|
|
|
def base_url(srv):
|
|
host, port = srv.server_address[:2]
|
|
return "http://{}:{}".format(host, port)
|
|
|
|
|
|
def get(srv, path, headers=None):
|
|
request = urllib.request.Request(base_url(srv) + path, headers=headers or {})
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
return response.status, response.headers, response.read()
|
|
|
|
|
|
def test_health_endpoint(server):
|
|
srv, hub = server
|
|
status, _, body = get(srv, "/api/health")
|
|
assert status == 200
|
|
payload = json.loads(body)
|
|
assert payload["status"] == "ok"
|
|
assert payload["port"] == "/dev/ttyUSB0"
|
|
assert payload["mode"] == MODE_LOGIN
|
|
|
|
|
|
def test_last_returns_204_when_nothing_read(server):
|
|
srv, _ = server
|
|
status, _, _ = get(srv, "/api/last")
|
|
assert status == 204
|
|
|
|
|
|
def test_last_returns_the_tap(server):
|
|
srv, hub = server
|
|
hub.publish(make_event())
|
|
status, _, body = get(srv, "/api/last")
|
|
assert status == 200
|
|
assert json.loads(body)["id"] == 12886197
|
|
|
|
|
|
def test_taps_endpoint_honours_limit(server):
|
|
srv, hub = server
|
|
for value in range(5):
|
|
hub.publish(make_event(value=value))
|
|
_, _, body = get(srv, "/api/taps?limit=2")
|
|
assert len(json.loads(body)["taps"]) == 2
|
|
|
|
|
|
def test_unknown_path_is_404(server):
|
|
srv, _ = server
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
get(srv, "/api/nope")
|
|
assert excinfo.value.code == 404
|
|
|
|
|
|
def test_health_available_without_origin(server):
|
|
"""Monitoring must work for a plain client with no Origin header."""
|
|
srv, _ = server
|
|
status, _, _ = get(srv, "/api/health")
|
|
assert status == 200
|
|
|
|
|
|
# -- origin policy ----------------------------------------------------------
|
|
|
|
def test_cross_origin_refused_when_no_allowlist(server):
|
|
"""A third-party page must not be able to read card ids."""
|
|
srv, _ = server
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
get(srv, "/api/last", headers={"Origin": "https://evil.example"})
|
|
assert excinfo.value.code == 403
|
|
|
|
|
|
def test_allowlisted_origin_permitted_and_echoed():
|
|
hub = EventHub()
|
|
srv = create_server(hub, host="127.0.0.1", port=0,
|
|
allowed_origins=["https://kiosk.example"])
|
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
hub.publish(make_event())
|
|
status, headers, body = get(
|
|
srv, "/api/last", headers={"Origin": "https://kiosk.example"}
|
|
)
|
|
assert status == 200
|
|
assert headers.get("Access-Control-Allow-Origin") == "https://kiosk.example"
|
|
assert json.loads(body)["id"] == 12886197
|
|
finally:
|
|
srv.shutdown()
|
|
srv.server_close()
|
|
|
|
|
|
def test_unlisted_origin_still_refused_when_allowlist_set():
|
|
hub = EventHub()
|
|
srv = create_server(hub, host="127.0.0.1", port=0,
|
|
allowed_origins=["https://kiosk.example"])
|
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
get(srv, "/api/last", headers={"Origin": "https://other.example"})
|
|
assert excinfo.value.code == 403
|
|
finally:
|
|
srv.shutdown()
|
|
srv.server_close()
|
|
|
|
|
|
def test_token_not_required_from_loopback():
|
|
hub = EventHub()
|
|
srv = create_server(hub, host="127.0.0.1", port=0, token="sekrit")
|
|
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, _, _ = get(srv, "/api/health")
|
|
assert status == 200
|
|
finally:
|
|
srv.shutdown()
|
|
srv.server_close()
|
|
|
|
|
|
# -- mode switching ---------------------------------------------------------
|
|
|
|
def post(srv, path, payload, headers=None):
|
|
data = json.dumps(payload).encode()
|
|
request = urllib.request.Request(
|
|
base_url(srv) + path, data=data, method="POST",
|
|
headers={"Content-Type": "application/json", **(headers or {})},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
return response.status, response.read()
|
|
|
|
|
|
def test_mode_can_be_switched(server):
|
|
srv, hub = server
|
|
status, body = post(srv, "/api/mode", {"mode": MODE_MONITOR})
|
|
assert status == 200
|
|
assert json.loads(body)["mode"] == MODE_MONITOR
|
|
assert hub.mode == MODE_MONITOR
|
|
|
|
|
|
def test_invalid_mode_rejected(server):
|
|
srv, _ = server
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
post(srv, "/api/mode", {"mode": "nonsense"})
|
|
assert excinfo.value.code == 400
|
|
|
|
|
|
def test_malformed_json_rejected(server):
|
|
srv, _ = server
|
|
request = urllib.request.Request(
|
|
base_url(srv) + "/api/mode", data=b"{not json",
|
|
method="POST", headers={"Content-Type": "application/json"},
|
|
)
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
urllib.request.urlopen(request, timeout=5)
|
|
assert excinfo.value.code == 400
|
|
|
|
|
|
# -- SSE --------------------------------------------------------------------
|
|
|
|
def test_sse_stream_delivers_a_tap(server):
|
|
"""The stream used by the web page must push a tap as it happens."""
|
|
srv, hub = server
|
|
received = []
|
|
|
|
def listen():
|
|
with urllib.request.urlopen(base_url(srv) + "/api/events", timeout=10) as resp:
|
|
for raw in resp:
|
|
line = raw.decode("utf-8").strip()
|
|
if line.startswith("data: "):
|
|
received.append(json.loads(line[6:]))
|
|
return
|
|
|
|
thread = threading.Thread(target=listen, daemon=True)
|
|
thread.start()
|
|
time.sleep(0.4) # let the subscription register
|
|
hub.publish(make_event())
|
|
thread.join(timeout=6)
|
|
assert received and received[0]["id"] == 12886197
|
|
|
|
|
|
def test_loopback_helper():
|
|
assert bridge.is_loopback("127.0.0.1") is True
|
|
assert bridge.is_loopback("0.0.0.0") is False
|
|
|
|
|
|
# -- latched card (the "always available" behaviour) ------------------------
|
|
|
|
def test_card_not_present_before_any_tap():
|
|
hub = EventHub()
|
|
state = hub.current_card()
|
|
assert state["present"] is False
|
|
assert state["card"] is None
|
|
|
|
|
|
def test_card_stays_available_after_the_page_loads():
|
|
"""The tap must outlive the page that reads it."""
|
|
hub = EventHub()
|
|
hub.publish(make_event(value=555))
|
|
for _ in range(3): # simulated later page loads
|
|
state = hub.current_card()
|
|
assert state["present"] is True
|
|
assert state["card"]["id"] == 555
|
|
|
|
|
|
def test_card_cleared_when_removed():
|
|
"""Lifting the card must NOT lose the tap - only mark it as gone."""
|
|
hub = EventHub(tap_ttl=30)
|
|
hub.publish(make_event())
|
|
hub.publish(make_event("card_removed"))
|
|
state = hub.current_card()
|
|
assert state["present"] is False # no longer physically on the reader
|
|
assert state["claimable"] is True # but still spendable
|
|
assert state["card"]["id"] == 12886197
|
|
|
|
|
|
def test_quick_tap_is_not_lost():
|
|
"""The real-world case: card lifted before the page polls."""
|
|
hub = EventHub(tap_ttl=10)
|
|
hub.publish(make_event(value=31337))
|
|
hub.publish(make_event("card_removed")) # lifted immediately
|
|
payload, error, _ = hub.consume_card()
|
|
assert error is None
|
|
assert payload["card"]["id"] == 31337
|
|
|
|
|
|
def test_claim_returns_the_card_once():
|
|
hub = EventHub()
|
|
hub.publish(make_event(value=777))
|
|
payload, error, status = hub.consume_card()
|
|
assert error is None and status == 200
|
|
assert payload["card"]["id"] == 777
|
|
assert payload["consumed"] is True
|
|
|
|
|
|
def test_claim_is_single_use():
|
|
"""A page reload must not turn one physical tap into two logins."""
|
|
hub = EventHub()
|
|
hub.publish(make_event())
|
|
assert hub.consume_card()[1] is None
|
|
payload, error, status = hub.consume_card()
|
|
assert payload is None
|
|
assert error == "card already used"
|
|
assert status == 409
|
|
|
|
|
|
def test_claim_without_a_card_is_conflict():
|
|
hub = EventHub()
|
|
payload, error, status = hub.consume_card()
|
|
assert payload is None
|
|
assert error == "no card read yet"
|
|
assert status == 409
|
|
|
|
|
|
def test_removing_and_replacing_the_card_allows_a_new_claim():
|
|
"""Lifting the card and tapping it again is a fresh authentication."""
|
|
hub = EventHub(tap_ttl=30)
|
|
hub.publish(make_event(value=111))
|
|
assert hub.consume_card()[1] is None
|
|
hub.publish(make_event("card_removed"))
|
|
hub.publish(make_event(value=111))
|
|
payload, error, _ = hub.consume_card()
|
|
assert error is None
|
|
assert payload["card"]["id"] == 111
|
|
|
|
|
|
def test_card_endpoint_reports_claimable_after_lift(server):
|
|
srv, hub = server
|
|
hub.publish(make_event(value=8080))
|
|
hub.publish(make_event("card_removed"))
|
|
_, _, body = get(srv, "/api/card")
|
|
payload = json.loads(body)
|
|
assert payload["present"] is False
|
|
assert payload["claimable"] is True
|
|
assert payload["card"]["id"] == 8080
|
|
|
|
|
|
def test_claim_expires_after_ttl():
|
|
hub = EventHub(tap_ttl=0.05)
|
|
hub.publish(make_event())
|
|
time.sleep(0.12)
|
|
payload, error, status = hub.consume_card()
|
|
assert payload is None
|
|
assert error == "card tap expired"
|
|
assert status == 410
|
|
|
|
|
|
def test_ttl_zero_never_expires_while_present():
|
|
hub = EventHub(tap_ttl=0)
|
|
hub.publish(make_event())
|
|
state = hub.current_card()
|
|
assert state["expired"] is False
|
|
assert state["claimable"] is True
|
|
|
|
|
|
def test_tap_becomes_unclaimable_after_ttl():
|
|
hub = EventHub(tap_ttl=0.05)
|
|
hub.publish(make_event())
|
|
time.sleep(0.12)
|
|
assert hub.current_card()["claimable"] is False
|
|
|
|
|
|
def test_a_new_tap_replaces_an_unclaimed_one():
|
|
hub = EventHub(tap_ttl=30)
|
|
hub.publish(make_event(value=111))
|
|
hub.publish(make_event(value=222))
|
|
payload, error, _ = hub.consume_card()
|
|
assert error is None
|
|
assert payload["card"]["id"] == 222
|
|
|
|
|
|
def test_health_shows_card_presence():
|
|
hub = EventHub()
|
|
hub.publish(make_event())
|
|
health = hub.health()
|
|
assert health["card_present"] is True
|
|
assert health["card_consumed"] is False
|
|
|
|
|
|
# -- latched card over HTTP -------------------------------------------------
|
|
|
|
def test_card_endpoint_reports_presence(server):
|
|
srv, hub = server
|
|
_, _, body = get(srv, "/api/card")
|
|
assert json.loads(body)["present"] is False
|
|
|
|
hub.publish(make_event(value=4242))
|
|
_, _, body = get(srv, "/api/card")
|
|
payload = json.loads(body)
|
|
assert payload["present"] is True
|
|
assert payload["card"]["id"] == 4242
|
|
|
|
|
|
def test_claim_endpoint_works_once(server):
|
|
srv, hub = server
|
|
hub.publish(make_event(value=9999))
|
|
|
|
status, body = post(srv, "/api/card/claim", {})
|
|
assert status == 200
|
|
assert json.loads(body)["card"]["id"] == 9999
|
|
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
post(srv, "/api/card/claim", {})
|
|
assert excinfo.value.code == 409
|
|
detail = json.loads(excinfo.value.read())
|
|
assert detail["error"] == "card already used"
|
|
|
|
|
|
def test_claim_endpoint_without_card_conflicts(server):
|
|
srv, _ = server
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
post(srv, "/api/card/claim", {})
|
|
assert excinfo.value.code == 409
|
|
|
|
|
|
def test_claim_refused_from_other_origin(server):
|
|
"""Another site must not be able to claim the card either."""
|
|
srv, hub = server
|
|
hub.publish(make_event())
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
post(srv, "/api/card/claim", {},
|
|
headers={"Origin": "https://evil.example"})
|
|
assert excinfo.value.code == 403
|
|
# the tap must still be claimable by the real caller
|
|
assert hub.current_card()["consumed"] is False
|
|
|
|
|
|
def test_claim_does_not_consume_on_refused_origin(server):
|
|
srv, hub = server
|
|
hub.publish(make_event(value=1234))
|
|
with pytest.raises(urllib.error.HTTPError):
|
|
post(srv, "/api/card/claim", {},
|
|
headers={"Origin": "https://evil.example"})
|
|
payload, error, _ = hub.consume_card()
|
|
assert error is None
|
|
assert payload["card"]["id"] == 1234
|