chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user