"""End-to-end test: a real tap must be claimable by a web page at any time. Simulates the RDM6300 on a pseudo-terminal, starts the actual bridge the way systemd would, then walks the exact flow a web page performs: card placed -> page loads -> polls /api/card -> claims it -> hand over once Also asserts the two things that matter for security: the tap survives until claimed, and it cannot be claimed twice. """ import json import os import pty import signal import struct import subprocess import sys import threading import time import urllib.error import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) PROJECT = os.path.dirname(HERE) VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python") PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable CARD_VALUE = 0x00C4A0B5 EXPECTED_ID = 12886197 def em4100_frame(value, card_type=0x0F): """12 hex nibbles: type + 4 id bytes + XOR checksum of those 5 bytes.""" id_bytes = struct.pack(">I", value) checksum = card_type for b in id_bytes: checksum ^= b frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper() assert len(frame) == 12, frame return frame.encode("ascii") def main(): master, slave = pty.openpty() serial_port = os.ttyname(slave) # Port 0 would be cleaner but the CLI needs a real number; pick a free one. import socket probe = socket.socket() probe.bind(("127.0.0.1", 0)) http_port = probe.getsockname()[1] probe.close() proc = subprocess.Popen( [PYTHON, "-m", "rdm_card_reader", "--serve", "--port", serial_port, "--bridge-port", str(http_port), "--mode", "login", "--tap-ttl", "15", "--allow-origin", "https://kiosk.example"], cwd=PROJECT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True, ) output = [] lock = threading.Lock() def pump(): for line in proc.stdout: with lock: output.append(line.rstrip()) threading.Thread(target=pump, daemon=True).start() base = "http://127.0.0.1:{}".format(http_port) results = {} def api(path, method="GET", headers=None): request = urllib.request.Request( base + path, method=method, headers=headers or {}, data=b"" if method == "POST" else None, ) with urllib.request.urlopen(request, timeout=5) as response: body = response.read() return response.status, (json.loads(body) if body else None) try: # Wait for the HTTP server to come up. deadline = time.time() + 15 while time.time() < deadline: try: api("/api/health") break except Exception: time.sleep(0.3) results["bridge started"] = True # The child needs a moment to open the serial port; frames written # before that are discarded, which would look like a reader bug. time.sleep(2.0) # --- no card yet ------------------------------------------------- _, state = api("/api/card") results["no card -> nothing claimable"] = state["claimable"] is False # --- a single tap, as a person would ------------------------------- frame = em4100_frame(CARD_VALUE) os.write(master, b"\x02" + frame + b"\x03") time.sleep(0.3) # --- a page loads later and polls; the card must still be there ---- time.sleep(1.0) _, state = api("/api/card") with lock: print("state after tap: {}".format(json.dumps(state)), flush=True) for line in output[-12:]: print(" " + line) results["card still available later"] = bool( state["claimable"] and state["card"]["id"] == EXPECTED_ID ) results["card not consumed yet"] = state["consumed"] is False # --- claim it, as the web page does ------------------------------- status, claimed = api( "/api/card/claim", method="POST", headers={"Origin": "https://kiosk.example"}, ) results["claim returns 200"] = status == 200 results["claim gives the right card"] = claimed["card"]["id"] == EXPECTED_ID # --- single use --------------------------------------------------- try: api("/api/card/claim", method="POST", headers={"Origin": "https://kiosk.example"}) results["second claim refused"] = False except urllib.error.HTTPError as exc: results["second claim refused"] = exc.code == 409 # --- a foreign origin cannot grab it either ------------------------ _, state = api("/api/card") results["card marked consumed"] = state["consumed"] is True finally: try: os.killpg(os.getpgid(proc.pid), signal.SIGINT) except Exception: pass try: proc.wait(timeout=8) except Exception: proc.kill() try: os.close(master) os.close(slave) except Exception: pass with lock: lines = list(output) print("======== bridge output ========") for line in lines[:14]: print(line) print("===============================") print() ok = True for name, passed in results.items(): print(" [{}] {}".format("PASS" if passed else "FAIL", name)) ok = ok and bool(passed) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())