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

229 lines
7.5 KiB
Python

"""Tests for poll-issued session-ticket enforcement on the SSE upgrade.
Two layers:
* Broker unit tests for ``claim_ticket`` / ``validate_ticket`` (issue, match,
mismatch, eviction, absence).
* Route tests proving the real CLI loop still works: ``/poll`` issues a
ticket and ``session_events`` accepts *that* ticket, while a mismatched
ticket is rejected with ``410`` before any stream opens.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from flask import Flask
from application.api.devices import auth as auth_module
from application.api.devices import session as session_module
from application.devices.broker import DeviceBroker
from .conftest import FakeRedis
# ---------------------------------------------------------------------------
# Broker unit tests
# ---------------------------------------------------------------------------
def _queue_work(broker: DeviceBroker, device_id: str = "dev_x") -> None:
# Queuing a command (no draining session) is what makes claim_ticket
# hand out a ticket: the device's command list is non-empty.
broker.dispatch_invocation(
device_id, "user_x", {"invocation_id": "inv_1", "action": "run_command"}
)
def test_claim_ticket_none_when_no_work(broker_env):
broker, _fake = broker_env
assert broker.claim_ticket("dev_x", 30) is None
def test_validate_accepts_issued_ticket(broker_env):
broker, _fake = broker_env
_queue_work(broker)
ticket = broker.claim_ticket("dev_x", 30)
assert ticket is not None
assert broker.validate_ticket("dev_x", ticket) is True
def test_validate_rejects_wrong_ticket(broker_env):
broker, _fake = broker_env
_queue_work(broker)
broker.claim_ticket("dev_x", 30)
assert broker.validate_ticket("dev_x", "st_not_the_one") is False
def test_validate_rejects_when_no_ticket_issued(broker_env):
broker, _fake = broker_env
assert broker.validate_ticket("dev_x", "st_anything") is False
def test_validate_rejects_empty_session_id(broker_env):
broker, _fake = broker_env
_queue_work(broker)
broker.claim_ticket("dev_x", 30)
assert broker.validate_ticket("dev_x", "") is False
def test_validate_rejects_after_ticket_evicted(broker_env):
# Redis enforces the TTL; once the ticket key is gone (expired/evicted),
# validate can't resurrect it.
broker, fake = broker_env
_queue_work(broker)
ticket = broker.claim_ticket("dev_x", 30)
fake.delete("dev:ticket:dev_x") # simulate TTL expiry
assert broker.validate_ticket("dev_x", ticket) is False
def test_claim_ticket_reused_while_unexpired(broker_env):
broker, _fake = broker_env
_queue_work(broker)
first = broker.claim_ticket("dev_x", 30)
second = broker.claim_ticket("dev_x", 30)
assert first == second
def test_register_session_consumes_issued_ticket_as_session_id(broker_env):
# The issued ticket becomes the session_id, so the URL the CLI opens
# (= the ticket) matches the live session.
broker, fake = broker_env
_queue_work(broker)
ticket = broker.claim_ticket("dev_x", 30)
sess = broker.register_session("dev_x", "user_x")
assert sess.session_id == ticket
# Ticket is consumed on registration.
assert fake.get("dev:ticket:dev_x") is None
# ---------------------------------------------------------------------------
# Route tests (poll -> SSE upgrade)
# ---------------------------------------------------------------------------
@pytest.fixture
def app():
return Flask(__name__)
def _device_row() -> dict:
return {
"id": "dev_route",
"user_id": "user_route",
"name": "laptop",
"status": "active",
"approval_mode": "ask",
"machine_pubkey_fingerprint": "fp",
"token_hash": "tokhash",
}
class _Repo:
def __init__(self, _conn):
pass
def find_by_token_hash(self, _token_hash):
return _device_row()
def touch_last_seen(self, _device_id):
pass
class _Ctx:
def __enter__(self):
return None
def __exit__(self, *a):
return False
def _patched_auth():
return [
patch.object(auth_module, "DevicesRepository", _Repo),
patch.object(auth_module, "db_readonly", _Ctx),
patch.object(auth_module, "db_session", _Ctx),
]
def _call(app: Flask, view, path: str, *args):
with app.test_request_context(
path, method="GET", headers={"Authorization": "Bearer tok_good"}
):
ctxs = _patched_auth()
for c in ctxs:
c.start()
try:
return view(*args)
finally:
for c in ctxs:
c.stop()
def test_poll_to_sse_upgrade_with_issued_ticket_works(app):
"""The legitimate loop: /poll issues a ticket, session_events accepts it."""
fake = FakeRedis()
broker = DeviceBroker()
with patch("application.devices.broker.get_redis_instance", return_value=fake):
# Queue work so /poll returns a ticket rather than 202.
broker.dispatch_invocation(
"dev_route", "user_route",
{"invocation_id": "inv_a", "action": "run_command"},
)
with patch.object(session_module, "get_broker", return_value=broker):
poll_resp = _call(app, session_module.poll, "/api/devices/poll")
assert poll_resp.status_code == 200
payload = poll_resp.get_json()
ticket = payload["session_ticket"]
assert payload["session_url"] == f"/api/devices/sessions/{ticket}/events"
assert payload["expires_in"] == 30
# CLI opens the exact session_url it was handed.
sse_resp = _call(
app,
session_module.session_events,
f"/api/devices/sessions/{ticket}/events",
ticket,
)
try:
assert sse_resp.status_code == 200
assert sse_resp.mimetype == "text/event-stream"
finally:
sse_resp.close()
def test_session_events_rejects_mismatched_ticket(app):
"""A fabricated/mismatched session_id is 410 Gone, no stream opened."""
fake = FakeRedis()
broker = DeviceBroker()
with patch("application.devices.broker.get_redis_instance", return_value=fake):
broker.dispatch_invocation(
"dev_route", "user_route",
{"invocation_id": "inv_b", "action": "run_command"},
)
with patch.object(session_module, "get_broker", return_value=broker):
# Poll issues the real ticket...
_call(app, session_module.poll, "/api/devices/poll")
# ...but the client opens a different one.
resp = _call(
app,
session_module.session_events,
"/api/devices/sessions/st_bogus/events",
"st_bogus",
)
assert resp.status_code == 410
assert resp.get_json()["error"] == "session_ticket_invalid"
def test_session_events_rejects_when_never_polled(app):
"""Opening the SSE stream without a prior poll is rejected (410)."""
fake = FakeRedis()
broker = DeviceBroker()
with patch("application.devices.broker.get_redis_instance", return_value=fake):
with patch.object(session_module, "get_broker", return_value=broker):
resp = _call(
app,
session_module.session_events,
"/api/devices/sessions/st_anything/events",
"st_anything",
)
assert resp.status_code == 410
assert resp.get_json()["error"] == "session_ticket_invalid"