Files
DocsGPT/tests/devices/test_pairing_create.py
T
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01: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 docsgpt.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")