Plan 3: pair CLI (POST /api/pair, write config.json)
This commit is contained in:
+61
-2
@@ -1,8 +1,67 @@
|
|||||||
"""Pairing CLI; see Plan 3 Task 6 for the full implementation."""
|
"""Pair this Pi with the backend.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m client.pair --backend https://backend.example.com [--code XYZ12345]
|
||||||
|
|
||||||
|
Called from install.sh after it prompts the user for the code.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from client.config import Config, save_config
|
||||||
|
from client.log import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("pair")
|
||||||
|
|
||||||
|
CLIENT_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def pair(*, backend: str, code: str | None, hostname: str) -> None:
|
||||||
|
backend = backend.rstrip("/")
|
||||||
|
if not code:
|
||||||
|
code = input("Pairing code: ").strip()
|
||||||
|
if not code:
|
||||||
|
raise SystemExit("no pairing code supplied")
|
||||||
|
|
||||||
|
url = f"{backend}/api/pair"
|
||||||
|
payload = {"code": code, "hostname": hostname, "client_version": CLIENT_VERSION}
|
||||||
|
_log.info("POST %s code=%s hostname=%s", url, code, hostname)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=30)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise SystemExit(f"pair request failed: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error", resp.text)
|
||||||
|
except ValueError:
|
||||||
|
err = resp.text
|
||||||
|
raise SystemExit(f"/api/pair returned {resp.status_code}: {err}")
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
cfg = Config(
|
||||||
|
device_id=str(body["device_id"]),
|
||||||
|
device_token=str(body["device_token"]),
|
||||||
|
backend_ws=str(body["backend_ws"]),
|
||||||
|
)
|
||||||
|
save_config(cfg)
|
||||||
|
_log.info("paired device %s; config saved", cfg.device_id)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
raise SystemExit("client.pair: not yet implemented (see Plan 3 Task 6).")
|
parser = argparse.ArgumentParser(description="Pair this Pi with the backend.")
|
||||||
|
parser.add_argument("--backend", required=True, help="Backend base URL (https://...)")
|
||||||
|
parser.add_argument("--code", default=None, help="Pairing code (prompted if omitted)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--hostname",
|
||||||
|
default=socket.gethostname(),
|
||||||
|
help="Hostname reported to the backend (defaults to socket.gethostname())",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
pair(backend=args.backend, code=args.code, hostname=args.hostname)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
|
||||||
|
from client.config import load_config
|
||||||
|
from client.pair import pair
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_pair_posts_to_api_pair_and_writes_config(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
responses.post(
|
||||||
|
"https://backend.test/api/pair",
|
||||||
|
json={
|
||||||
|
"device_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"device_token": "T0K3N",
|
||||||
|
"backend_ws": "wss://backend.test/device",
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
pair(backend="https://backend.test", code="ABC12345", hostname="testhost")
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
assert cfg.device_id == "00000000-0000-0000-0000-000000000001"
|
||||||
|
assert cfg.device_token == "T0K3N"
|
||||||
|
assert cfg.backend_ws == "wss://backend.test/device"
|
||||||
|
|
||||||
|
body = responses.calls[0].request.body
|
||||||
|
assert b"ABC12345" in body
|
||||||
|
assert b"testhost" in body
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_pair_strips_trailing_slash_from_backend(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
responses.post(
|
||||||
|
"https://backend.test/api/pair",
|
||||||
|
json={"device_id": "x", "device_token": "y", "backend_ws": "wss://z"},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
pair(backend="https://backend.test/", code="ABC12345", hostname="h")
|
||||||
|
assert responses.calls[0].request.url == "https://backend.test/api/pair"
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_pair_raises_on_404(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
responses.post(
|
||||||
|
"https://backend.test/api/pair",
|
||||||
|
json={"error": "code invalid or expired"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
pair(backend="https://backend.test", code="WRONGCOD", hostname="h")
|
||||||
|
assert "code invalid or expired" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_used_when_code_not_provided(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setattr("sys.stdin", io.StringIO("FROMSTDIN\n"))
|
||||||
|
|
||||||
|
called_with = {}
|
||||||
|
|
||||||
|
def fake_post(url, json, timeout):
|
||||||
|
called_with["json"] = json
|
||||||
|
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self_):
|
||||||
|
return {
|
||||||
|
"device_id": "x",
|
||||||
|
"device_token": "y",
|
||||||
|
"backend_ws": "wss://z",
|
||||||
|
}
|
||||||
|
|
||||||
|
def raise_for_status(self_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return R()
|
||||||
|
|
||||||
|
monkeypatch.setattr("client.pair.requests.post", fake_post)
|
||||||
|
pair(backend="https://backend.test", code=None, hostname="h")
|
||||||
|
assert called_with["json"]["code"] == "FROMSTDIN"
|
||||||
Reference in New Issue
Block a user