178 lines
5.5 KiB
Python
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())
|