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