Files
rdm-card-reader/tests/test_e2e_bridge.py
T
ske087 576f22268f RDM card reader: print card events, select the right port, serve taps to a web app
Card reading (rdm6300 + pyserial) printing card inserted/removed with the
full card data, plus a --serve mode that publishes taps over HTTP so a web
page can authenticate a tap.

The bridge reports card ids only - mapping a card to a user stays on the
server. The tap is latched for --tap-ttl seconds, including after the card is
lifted, and is single use, so a page reload cannot replay one tap into two
logins.

Port selection identifies the reader by serial number or by-id path instead of
/dev/ttyUSBn, and refuses to guess when a selector matches several adapters.

deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and
install.sh. Covered by unit tests plus PTY end-to-end tests.
2026-10-01 15:31:04 +03:00

178 lines
5.5 KiB
Python

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