Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

284 lines
8.7 KiB
Python

"""Tests for the create-pairing endpoint's optional preset fields.
Covers the friction-reducer A change: ``POST /api/devices/pairings`` now
accepts ``name``, ``description``, and ``approval_mode`` in the body and
stashes them in Redis so ``redeem_pairing`` can apply them to the new
``devices`` row. These tests exercise the function directly (no Flask
test client) because the broader pairing flow needs Redis + Postgres
fixtures that aren't wired into this module yet.
"""
from __future__ import annotations
import json
from typing import Optional
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from application.api.devices import pairing as pairing_module
class _StubRedis:
"""Minimal Redis stand-in capturing setex payloads.
``eval`` re-implements the redeem-claim Lua: read the pairing JSON and,
only if ``status == 'pending'``, flip it to ``redeemed`` (returning 1),
else return 0. This lets the redeem tests exercise the atomic path.
"""
def __init__(self) -> None:
self.setex_calls: list[tuple[str, int, str]] = []
self.store: dict[str, str] = {}
def setex(self, key: str, ttl: int, value: str) -> None:
self.setex_calls.append((key, ttl, value))
self.store[key] = value
def get(self, key: str) -> Optional[str]:
return self.store.get(key)
def delete(self, key: str) -> None:
self.store.pop(key, None)
def eval(self, _script: str, _numkeys: int, key: str, *_args) -> int:
raw = self.store.get(key)
if raw is None:
return 0
try:
state = json.loads(raw)
except Exception:
return 0
if state.get("status") != "pending":
return 0
state["status"] = "redeemed"
self.store[key] = json.dumps(state)
return 1
@pytest.fixture
def app():
return Flask(__name__)
def _call_create(app: Flask, body: dict, redis: _StubRedis):
with app.test_request_context(
"/api/devices/pairings",
method="POST",
json=body,
):
from flask import request as flask_request
flask_request.decoded_token = {"sub": "user_abc"}
with patch.object(pairing_module, "_redis", return_value=redis):
return pairing_module.create_pairing()
def _stashed_state(redis: _StubRedis) -> dict:
assert redis.setex_calls, "no redis writes recorded"
# The first setex is the device_code -> state hash; the second is the
# user_code -> device_code index.
raw = redis.setex_calls[0][2]
return json.loads(raw)
def test_create_pairing_stashes_name_description_and_mode(app):
redis = _StubRedis()
response = _call_create(
app,
{
"name": " laptop ",
"description": " desk machine ",
"approval_mode": "full",
},
redis,
)
payload = response.get_json()
assert response.status_code == 200
assert payload["user_code"], "expected a user_code in the response"
state = _stashed_state(redis)
assert state["requested_name"] == "laptop"
assert state["requested_description"] == "desk machine"
assert state["requested_approval_mode"] == "full"
def test_create_pairing_defaults_when_body_omitted(app):
redis = _StubRedis()
response = _call_create(app, {}, redis)
assert response.status_code == 200
state = _stashed_state(redis)
assert state["requested_name"] is None
assert state["requested_description"] is None
assert state["requested_approval_mode"] is None
@pytest.mark.parametrize("mode", ["bogus", "writes-only", "never"])
def test_create_pairing_rejects_invalid_approval_mode(app, mode):
redis = _StubRedis()
response = _call_create(app, {"approval_mode": mode}, redis)
assert response.status_code == 400
body = response.get_json()
assert body["error"] == "invalid_approval_mode"
assert not redis.setex_calls
@pytest.mark.parametrize("bad_name", [123, 12.5, True, ["x"], {"a": 1}])
def test_create_pairing_rejects_non_string_name(app, bad_name):
# A non-string ``name`` must 400 instead of crashing on ``.strip()``.
redis = _StubRedis()
response = _call_create(app, {"name": bad_name}, redis)
assert response.status_code == 400
assert response.get_json()["error"] == "invalid_name"
assert not redis.setex_calls
def test_create_pairing_blank_strings_normalize_to_none(app):
redis = _StubRedis()
response = _call_create(
app,
{"name": " ", "description": ""},
redis,
)
assert response.status_code == 200
state = _stashed_state(redis)
assert state["requested_name"] is None
assert state["requested_description"] is None
class _StubDevicesRepo:
def __init__(self) -> None:
self.create_args: dict = {}
def create(self, **kwargs) -> dict:
self.create_args = kwargs
return {"id": kwargs.get("device_id"), **kwargs}
class _StubConn:
def __init__(self) -> None:
self.executed: list = []
def execute(self, *args, **kwargs):
self.executed.append((args, kwargs))
# Return an object with .fetchone()/.rowcount where needed; for
# the upsert helper we only need .fetchone() returning None.
return MagicMock(fetchone=lambda: None, rowcount=0)
def test_redeem_applies_stashed_preferences(app, monkeypatch):
"""End-to-end: stash via create, then redeem applies the values."""
redis = _StubRedis()
# Step 1 — create with preset name/description/mode
create_response = _call_create(
app,
{
"name": "edgebox",
"description": "raspberry pi",
"approval_mode": "full",
},
redis,
)
create_payload = create_response.get_json()
user_code = create_payload["user_code"].replace("-", "")
# Step 2 — simulate the CLI hitting redeem.
devices_repo = _StubDevicesRepo()
monkeypatch.setattr(
pairing_module, "DevicesRepository", lambda conn: devices_repo
)
monkeypatch.setattr(
pairing_module,
"_upsert_remote_device_user_tool",
lambda conn, **kwargs: None,
)
class _Sess:
def __enter__(self):
return _StubConn()
def __exit__(self, *args):
return False
monkeypatch.setattr(pairing_module, "db_session", _Sess)
monkeypatch.setattr(
pairing_module,
"fingerprint_pubkey",
lambda _pubkey: "fpfpfp",
)
monkeypatch.setattr(
pairing_module,
"hash_session_token",
lambda _tok: "tokhash",
)
with app.test_request_context(
"/api/devices/pairings/redeem",
method="POST",
json={
"user_code": user_code,
"hostname": "myhost",
"os": "linux",
"arch": "amd64",
"cli_version": "0.5.0",
"machine_pubkey": "Zm9v",
},
):
with patch.object(pairing_module, "_redis", return_value=redis):
response = pairing_module.redeem_pairing()
assert response.status_code == 200, response.get_json()
args = devices_repo.create_args
assert args["approval_mode"] == "full"
assert args["description"] == "raspberry pi"
# Name prefix is the user-supplied value (collision suffix appended).
assert args["name"].startswith("edgebox")
def test_redeem_falls_back_to_hostname_and_ask(app, monkeypatch):
redis = _StubRedis()
create_response = _call_create(app, {}, redis)
user_code = create_response.get_json()["user_code"].replace("-", "")
devices_repo = _StubDevicesRepo()
monkeypatch.setattr(
pairing_module, "DevicesRepository", lambda conn: devices_repo
)
monkeypatch.setattr(
pairing_module,
"_upsert_remote_device_user_tool",
lambda conn, **kwargs: None,
)
class _Sess:
def __enter__(self):
return _StubConn()
def __exit__(self, *args):
return False
monkeypatch.setattr(pairing_module, "db_session", _Sess)
monkeypatch.setattr(
pairing_module, "fingerprint_pubkey", lambda _pubkey: "fpfpfp",
)
monkeypatch.setattr(
pairing_module, "hash_session_token", lambda _tok: "tokhash",
)
with app.test_request_context(
"/api/devices/pairings/redeem",
method="POST",
json={
"user_code": user_code,
"hostname": "fallback-host",
"os": "linux",
"arch": "amd64",
"machine_pubkey": "Zm9v",
},
):
with patch.object(pairing_module, "_redis", return_value=redis):
response = pairing_module.redeem_pairing()
assert response.status_code == 200
args = devices_repo.create_args
assert args["approval_mode"] == "ask"
assert args["description"] is None
assert args["name"].startswith("fallback-host")