Files
spoopyghosty0--frameport/tests/test_setup_url.py
T

291 lines
11 KiB
Python

"""The static setup URL: a Frame asks the pairing server (/hello), the user allows it, /wait hands over the code,
and bootstrap/setup.sh then runs the same bootstrap.sh as the typed line."""
import hashlib
import json
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from frameport.frame import pairing
from frameport.frame.pairing import PairingServer, announce_properties, ask_digits, pc_words
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def server():
srv = PairingServer(announce=False, host="127.0.0.1").start()
yield srv
srv.stop()
def get(srv, path, timeout=10):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{srv.port}{path}", timeout=timeout) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def hello(srv, nonce="abcdef0123456789", host="steamframe"):
status, body = get(srv, f"/hello?host={host}&nonce={nonce}")
assert status == 200
return json.loads(body)
def test_allow_hands_out_the_code(server):
asked = []
server.on_ask = asked.append
reply = hello(server)
assert reply["digits"] == ask_digits("abcdef0123456789") and reply["words"] == pc_words()
assert asked and asked[0].frame == "steamframe" and asked[0].digits == reply["digits"]
threading.Timer(0.3, server.decide, (reply["id"], True)).start()
status, body = get(server, f"/wait?id={reply['id']}")
assert status == 200 and json.loads(body) == {"code": server.code}
def test_deny_and_limits(server):
reply = hello(server)
server.decide(reply["id"], False)
assert get(server, f"/wait?id={reply['id']}")[0] == 403
assert get(server, "/wait?id=nope")[0] == 404
for i in range(pairing.MAX_ASKS):
hello(server, nonce=f"nonce{i:011d}")
assert get(server, "/hello?host=x&nonce=0123456789abcdef")[0] == 429
assert get(server, "/hello?host=x&nonce=short")[0] == 400
# nothing about the code without it
assert get(server, "/bootstrap.sh")[0] == 403
def test_wait_times_out_politely(server, monkeypatch):
monkeypatch.setattr(pairing, "WAIT", 0.2)
reply = hello(server)
assert get(server, f"/wait?id={reply['id']}")[0] == 202
def test_ping_and_announcement_never_carry_the_code(server):
status, body = get(server, "/ping")
info = json.loads(body)
assert status == 200 and info["words"] == pc_words() and server.code not in body.decode()
props = announce_properties("pc")
assert server.code not in json.dumps(props) and props["words"] == pc_words()
def test_digits_match_setup_sh():
"""setup.sh computes the digits with this exact Python expression."""
script = (ROOT / "bootstrap" / "setup.sh").read_text()
expr = 'int(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:8], 16) % 10000:04d'
assert expr in script
nonce = "0f1e2d3c4b5a69788796a5b4"
assert ask_digits(nonce) == f"{int(hashlib.sha256(nonce.encode()).hexdigest()[:8], 16) % 10000:04d}"
@pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash") or not shutil.which("curl"),
reason="needs bash and curl")
def test_setup_sh_end_to_end(tmp_path, monkeypatch):
"""The real setup.sh against a real server; bootstrap.sh is a stub that records the code it was served with."""
stub_dir = tmp_path / "bootstrap"
stub_dir.mkdir()
marker = tmp_path / "ran.txt"
(stub_dir / "bootstrap.sh").write_text(f'#!/usr/bin/env bash\necho "__PAIR_CODE__ __PC_URL__" > "{marker}"\n')
monkeypatch.setattr(pairing, "bootstrap_dir", lambda: stub_dir)
srv = PairingServer(announce=False, host="127.0.0.1")
srv.on_ask = lambda ask: threading.Timer(0.3, srv.decide, (ask.id, True)).start()
srv.start()
try:
r = subprocess.run(["bash", str(ROOT / "bootstrap" / "setup.sh"), "--pc", f"127.0.0.1:{srv.port}"],
capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL)
assert r.returncode == 0, r.stdout + r.stderr
assert marker.read_text().split()[0] == srv.code
digits = srv.asks[0].digits
assert digits in r.stdout # the terminal shows the same 4 digits as FramePort
finally:
srv.stop()
@pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash"), reason="needs bash")
def test_setup_sh_denied_changes_nothing(tmp_path, monkeypatch):
stub_dir = tmp_path / "bootstrap"
stub_dir.mkdir()
marker = tmp_path / "ran.txt"
(stub_dir / "bootstrap.sh").write_text(f'#!/usr/bin/env bash\ntouch "{marker}"\n')
monkeypatch.setattr(pairing, "bootstrap_dir", lambda: stub_dir)
srv = PairingServer(announce=False, host="127.0.0.1")
srv.on_ask = lambda ask: threading.Timer(0.2, srv.decide, (ask.id, False)).start()
srv.start()
try:
r = subprocess.run(["bash", str(ROOT / "bootstrap" / "setup.sh"), "--pc", f"127.0.0.1:{srv.port}"],
capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL)
assert r.returncode == 1 and "Not allowed" in r.stdout and not marker.exists()
finally:
srv.stop()
def test_announcement_registers_and_goes(monkeypatch):
"""With zeroconf: the service is registered while the server runs and withdrawn when it stops."""
events = []
class FakeZC:
def __init__(self, **kw):
pass
def register_service(self, info, **kw):
events.append(("register", info.name, dict(info.properties)))
def unregister_service(self, info):
events.append(("unregister", info.name))
def close(self):
pass
import zeroconf
monkeypatch.setattr(zeroconf, "Zeroconf", FakeZC)
srv = PairingServer(host="127.0.0.1").start()
for _ in range(50):
if events:
break
time.sleep(0.05)
srv.stop()
for _ in range(50):
if len(events) > 1:
break
time.sleep(0.05)
assert events[0][0] == "register" and events[0][1].endswith(pairing.SERVICE)
assert all(srv.code.encode() not in v for v in events[0][2].values())
assert events[-1][0] == "unregister"
def test_first_key_is_made_once_under_concurrency():
"""The setup page's announcement and its text ask for the app key at the same moment on a first start."""
from frameport.frame.connection import app_public_key
keys, errors = [], []
def get_key():
try:
keys.append(app_public_key())
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target=get_key) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors and len(set(keys)) == 1
def test_cli_pair_does_not_announce(monkeypatch):
"""`frameport frame pair` can't answer the setup URL's requests (no Allow button), so it must not announce
itself: a Frame running the setup URL would find it and wait for an answer that never comes."""
from typer.testing import CliRunner
from frameport import cli
made = []
class Fake:
def __init__(self, **kw):
made.append(kw)
self.paired = []
self.one_liner = "curl -fsS 127.0.0.1:8765/code | bash"
def start(self):
return self
def stop(self):
pass
monkeypatch.setattr(pairing, "PairingServer", Fake)
result = CliRunner().invoke(cli.app, ["frame", "pair", "--timeout", "0"])
assert result.exit_code == 1 and "timed out" in result.output
assert made and made[0].get("announce") is False
def test_open_requests_expire_when_polling_stops(server):
"""A Frame that stopped polling /wait (setup.sh ended) no longer blocks new requests, and its card goes."""
gone = []
server.on_expire = gone.extend
for i in range(pairing.MAX_ASKS):
hello(server, nonce=f"nonce{i:011d}")
assert get(server, "/hello?host=x&nonce=0123456789abcdef")[0] == 429
stale = server.asks[0]
stale.seen -= 2 * pairing.WAIT + 1
assert stale not in server.open_asks() # the UI drops it at once
status, _ = get(server, "/hello?host=x&nonce=0123456789abcdef")
assert status == 200 and stale not in server.asks and stale.state == "expired" and gone == [stale]
server.decide(stale.id, True) # Allow on a stale toast: nothing
assert stale.state == "expired"
assert get(server, f"/wait?id={stale.id}")[0] == 404
def test_polled_and_decided_requests(server):
now = time.time()
a = pairing.Ask("a", "f", "1.2.3.4", "0000", created=now - 10 * pairing.WAIT)
a.waiting = 1 # a /wait is held right now: still alive however old
b = pairing.Ask("b", "f", "1.2.3.4", "0000", state="allowed", created=now - 60)
b.decided = now - 60
c = pairing.Ask("c", "f", "1.2.3.4", "0000", state="denied", created=now - pairing.DECIDED_KEEP - 100)
c.decided = now - pairing.DECIDED_KEEP - 1
server.asks.extend([a, b, c])
assert server.prune(now) == [c] and server.asks == [a, b]
assert server.open_asks() == [a]
a.waiting = 0
assert server.prune(now) == [a]
def test_polling_keeps_a_request_alive(server, monkeypatch):
monkeypatch.setattr(pairing, "WAIT", 0.2)
reply = hello(server)
ask = server.asks[0]
ask.seen -= 100
assert get(server, f"/wait?id={reply['id']}")[0] == 202
assert time.time() - ask.seen < 1 and ask.waiting == 0 and server.open_asks() == [ask]
def test_expiry_loop_runs(monkeypatch):
monkeypatch.setattr(pairing, "EXPIRE_EVERY", 0.05)
gone = []
srv = PairingServer(announce=False, host="127.0.0.1", on_expire=gone.extend).start()
try:
srv.asks.append(pairing.Ask("x", "f", "1.2.3.4", "0000", created=time.time() - 3 * pairing.WAIT))
for _ in range(100):
if gone:
break
time.sleep(0.02)
assert [a.id for a in gone] == ["x"] and not srv.asks
finally:
srv.stop()
def test_only_open_requests_hold_off_auto_pairing(server):
"""Automatic pairing waits only while a Frame waits for Allow / Deny, not for decided or stale requests."""
assert not pairing.asking(None) and not pairing.asking(server)
hello(server)
assert pairing.asking(server)
server.decide(server.asks[0].id, False)
assert not pairing.asking(server) # denied: no longer waiting
hello(server, nonce="fedcba9876543210")
server.asks[-1].seen -= 3 * pairing.WAIT
assert not pairing.asking(server) # its Frame stopped asking
hello(server, nonce="00112233445566778899")
server.stop()
assert not pairing.asking(server) # setup page closed
@pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash") or not shutil.which("curl"),
reason="needs bash and curl")
def test_setup_sh_says_why_when_full(server):
for i in range(pairing.MAX_ASKS):
hello(server, nonce=f"nonce{i:011d}")
r = subprocess.run(["bash", str(ROOT / "bootstrap" / "setup.sh"), "--pc", f"127.0.0.1:{server.port}"],
capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL)
assert r.returncode == 1 and "already has several setup requests" in r.stdout
assert "didn't answer" not in r.stdout