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
284 lines
8.7 KiB
Python
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")
|