mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
497 lines
18 KiB
Python
497 lines
18 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import logging
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from typing import TYPE_CHECKING, Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
|
||
def pytest_sessionstart(session: pytest.Session) -> None:
|
||
"""Resolve MCP v1's generic FastMCP settings model for test servers.
|
||
|
||
MCP 1.29.0 defines ``Settings`` before ``FastMCP``, so its ``lifespan``
|
||
annotation remains an unresolved forward reference after import. Rebuild
|
||
once, after the module is fully loaded, before any integration fixture
|
||
constructs a FastMCP server. Pydantic's public hook is a no-op once the SDK
|
||
ships a complete model.
|
||
"""
|
||
from mcp.server.fastmcp.server import Settings as FastMCPSettings
|
||
|
||
FastMCPSettings.model_rebuild()
|
||
|
||
|
||
def stop_loop_thread(loop: asyncio.AbstractEventLoop, thread: threading.Thread) -> None:
|
||
"""Fully tear down a ``loop.run_forever``-in-a-thread test loop.
|
||
|
||
Shuts the loop's default executor down ON the loop (joining its worker
|
||
threads — the ``asyncio_N`` threads that otherwise leak past the test),
|
||
then stops the loop, joins the thread, and closes the loop. Use in the
|
||
``finally`` of a background-loop fixture so nothing outlives the test.
|
||
"""
|
||
with contextlib.suppress(Exception):
|
||
asyncio.run_coroutine_threadsafe(loop.shutdown_default_executor(), loop).result(timeout=5)
|
||
loop.call_soon_threadsafe(loop.stop)
|
||
thread.join(timeout=5)
|
||
with contextlib.suppress(Exception):
|
||
loop.close()
|
||
|
||
|
||
def serve_until_exit(server: Any) -> None:
|
||
"""Run a uvicorn ``Server`` on a fresh event loop until it exits.
|
||
|
||
The thread target for an in-thread test upstream: when ``server.serve()``
|
||
returns (the fixture set ``server.should_exit`` / ``force_exit``), the loop
|
||
is closed so it doesn't leak past the fixture.
|
||
"""
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
loop.run_until_complete(server.serve())
|
||
finally:
|
||
# Cancel + drain anything the app left pending (e.g. sse_starlette's
|
||
# shutdown watcher) so loop.close() doesn't warn "Task was destroyed
|
||
# but it is pending".
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
if pending:
|
||
with contextlib.suppress(Exception):
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
loop.close()
|
||
|
||
|
||
class _PendingResolver:
|
||
"""Race-free drop-in for ``threading.Timer(delay, ui.resolve_approval)``.
|
||
|
||
``approve_tools`` runs ``_approval_event.clear()`` -> register
|
||
``_pending_approval`` -> ``_approval_event.wait(_APPROVAL_WAIT_TIMEOUT)``
|
||
(3600s). A *fixed-delay* timer can fire ``resolve_approval``
|
||
(``_approval_event.set()``) BEFORE that ``.clear()`` on a slow/loaded
|
||
runner, so the set is wiped by the clear and ``approve_tools`` blocks the
|
||
full hour -- surfacing as a CI hang. This instead waits until the approval
|
||
is actually registered (which happens *after* the clear), then resolves, so
|
||
the wakeup can never be lost. ``start()`` / ``cancel()`` mirror
|
||
``threading.Timer`` so it drops into existing scaffolding. ``cancel()``
|
||
signals the worker to stop and joins it, so a test that errors *before* the
|
||
approval registers can't leak the thread or resolve late into a finished
|
||
test. ``before`` runs just before resolving -- e.g. to snapshot
|
||
pending-state fields the test asserts on.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
ui: Any,
|
||
*args: Any,
|
||
before: Callable[[], None] | None = None,
|
||
deadline: float = 10.0,
|
||
**kwargs: Any,
|
||
) -> None:
|
||
self._ui = ui
|
||
self._args = args
|
||
self._kwargs = kwargs
|
||
self._before = before
|
||
self._deadline = deadline
|
||
self._cancelled = threading.Event()
|
||
self._started = False
|
||
self._thread = threading.Thread(target=self._run, name="resolve-when-pending", daemon=True)
|
||
|
||
def _run(self) -> None:
|
||
end = time.monotonic() + self._deadline
|
||
while time.monotonic() < end:
|
||
if self._cancelled.is_set():
|
||
return
|
||
# getattr (not a bare read) so a UI without _pending_approval can't
|
||
# crash the worker into a silent death that leaves approve_tools
|
||
# blocked for the full _APPROVAL_WAIT_TIMEOUT.
|
||
if getattr(self._ui, "_pending_approval", None) is not None:
|
||
if self._before is not None:
|
||
self._before()
|
||
self._ui.resolve_approval(*self._args, **self._kwargs)
|
||
return
|
||
time.sleep(0.001)
|
||
# Deadline without registration: approve_tools isn't parked on the
|
||
# approval event (returned early, or never reached it) -- don't resolve
|
||
# into an unknown state; let the test's own assertions speak.
|
||
|
||
def start(self) -> None:
|
||
self._started = True
|
||
self._thread.start()
|
||
|
||
def cancel(self) -> None:
|
||
self._cancelled.set()
|
||
if self._started:
|
||
self._thread.join(timeout=5)
|
||
|
||
|
||
def resolve_when_pending(ui: Any, *args: Any, **kwargs: Any) -> _PendingResolver:
|
||
"""Build a race-free approval resolver (see :class:`_PendingResolver`)."""
|
||
return _PendingResolver(ui, *args, **kwargs)
|
||
|
||
|
||
if TYPE_CHECKING:
|
||
from collections.abc import Callable, Iterator
|
||
|
||
from turnstone.core.mcp_client import MCPClientManager, StaticServerState
|
||
from turnstone.core.mcp_crypto import MCPTokenCipher
|
||
from turnstone.core.oidc import OIDCConfig
|
||
|
||
|
||
# A background daemon (e.g. title generation) can log into pytest's per-test
|
||
# capture as it is torn down — a benign "I/O operation on closed file" handler
|
||
# error. Don't let the logging module turn that race into noisy stderr
|
||
# tracebacks. (Process-global, test-only — product runtime keeps the default.)
|
||
logging.raiseExceptions = False
|
||
|
||
|
||
# Threads a test leaves running after teardown bleed into LATER tests' captured
|
||
# output (the "I/O operation on closed file" heisenbug) and, worse, can wedge
|
||
# the whole run (a leaked event loop / server that never stops). This grace
|
||
# lets a legitimately-finishing quick daemon settle before we judge a leak.
|
||
_THREAD_LEAK_GRACE = 5.0
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _no_leaked_threads(request: pytest.FixtureRequest) -> Iterator[None]:
|
||
"""Fail a test that leaves a background thread running past teardown.
|
||
|
||
Snapshots the live threads at setup; at teardown, gives any NEW thread a
|
||
short grace to finish, then fails listing those still alive — so a leak is
|
||
caught here instead of as a heisenbug days later. Opt out with
|
||
``@pytest.mark.allow_thread_leak`` (e.g. module-scoped servers in the live
|
||
suite).
|
||
"""
|
||
if request.node.get_closest_marker("allow_thread_leak"):
|
||
yield
|
||
return
|
||
# Snapshot the Thread OBJECTS, not their idents: Thread.ident is recycled
|
||
# after a thread exits, so an ident-based snapshot could mistake a new
|
||
# leaked thread (reusing an exited thread's ident) for a pre-existing one.
|
||
before = set(threading.enumerate())
|
||
yield
|
||
main = threading.main_thread()
|
||
current = threading.current_thread()
|
||
# One deadline shared across all joined threads — a deliberate TOTAL
|
||
# teardown budget (not per-thread), so a pathological test can't stall
|
||
# teardown by N×grace. A genuine never-stopping leak exhausts it and fails.
|
||
deadline = time.monotonic() + _THREAD_LEAK_GRACE
|
||
leaked = []
|
||
for t in threading.enumerate():
|
||
if t in before or t is main or t is current or not t.is_alive():
|
||
continue
|
||
t.join(timeout=max(0.0, deadline - time.monotonic()))
|
||
if t.is_alive():
|
||
leaked.append(t.name)
|
||
if leaked:
|
||
pytest.fail(
|
||
f"test left background threads running after teardown: {leaked}. "
|
||
"Stop them in teardown (shut down servers / close event loops / join "
|
||
"threads), or mark @pytest.mark.allow_thread_leak if intentional."
|
||
)
|
||
|
||
|
||
def make_mcp_token_cipher() -> MCPTokenCipher:
|
||
"""Build a single-key MCP token cipher for tests.
|
||
|
||
Used by test files that need to exercise ``MCPTokenStore`` round-
|
||
trips without the lifespan-side configuration loader; centralised
|
||
here so the key/material defaults stay aligned across files.
|
||
"""
|
||
import base64
|
||
|
||
from cryptography.fernet import Fernet
|
||
|
||
from turnstone.core.mcp_crypto import MCPTokenCipher, MCPTokenCipherConfig
|
||
|
||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||
return MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,)))
|
||
|
||
|
||
def _seed_static_state(mgr: MCPClientManager, name: str, **overrides: Any) -> StaticServerState:
|
||
"""Get-or-create a ``StaticServerState`` on ``mgr`` and apply ``overrides``.
|
||
|
||
Shared across MCP test files so the helper stays in one place. Imported
|
||
where needed; ``StaticServerState`` is constructed lazily so non-MCP
|
||
tests don't pay the import cost.
|
||
"""
|
||
from turnstone.core.mcp_client import StaticServerState
|
||
|
||
state = mgr._static_servers.get(name)
|
||
if state is None:
|
||
state = StaticServerState(name=name)
|
||
mgr._static_servers[name] = state
|
||
for k, v in overrides.items():
|
||
setattr(state, k, v)
|
||
return state
|
||
|
||
|
||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 10) -> Any:
|
||
"""Submit *coro* to *loop*, wait for the result.
|
||
|
||
The ONE copy shared by the MCP test files — four hand-synced copies
|
||
had already drifted on the timeout (5s hardcoded vs a 10s default).
|
||
The timeout is an upper bound on waiting, not a behavior assertion,
|
||
so the most generous variant won the merge.
|
||
"""
|
||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||
return fut.result(timeout=timeout)
|
||
|
||
|
||
def _drain_background(mgr: MCPClientManager, loop: asyncio.AbstractEventLoop) -> None:
|
||
"""Deterministically await ``mgr``'s tracked background tasks.
|
||
|
||
Replaces fixed sleeps for synchronizing with scheduled dead-grant
|
||
drops / spawned refreshes: exact, and immune to slow-runner flake.
|
||
"""
|
||
|
||
async def _drain() -> None:
|
||
tasks = [t for t in list(mgr._background_tasks) if not t.done()]
|
||
if tasks:
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
_run_on_loop(loop, _drain())
|
||
|
||
|
||
def _poll_until(predicate: Callable[[], bool], timeout: float, interval: float = 0.05) -> bool:
|
||
"""Poll *predicate* until true or *timeout* elapses — the ONE wait loop.
|
||
|
||
Shared by the live MCP smoke tests' condition helpers so the
|
||
deadline/poll pattern doesn't accrete per-file hand-synced copies.
|
||
"""
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if predicate():
|
||
return True
|
||
time.sleep(interval)
|
||
return False
|
||
|
||
|
||
def _free_port() -> int:
|
||
"""Grab an ephemeral localhost port for a live-server subprocess.
|
||
|
||
Shared by the live MCP smoke tests (flaky-server, push-refresh) so
|
||
the socket-probe helpers stay in one place instead of drifting per
|
||
file.
|
||
"""
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
s.bind(("127.0.0.1", 0))
|
||
return int(s.getsockname()[1])
|
||
|
||
|
||
def _tcp_accepts(port: int) -> bool:
|
||
try:
|
||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _wait_tcp_ready(port: int, timeout: float) -> bool:
|
||
"""Poll until something accepts TCP on 127.0.0.1:*port* (live tests)."""
|
||
return _poll_until(lambda: _tcp_accepts(port), timeout)
|
||
|
||
|
||
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
|
||
"""Poll until static server *name* has a live session (live tests)."""
|
||
|
||
def _live() -> bool:
|
||
state = mgr._static_servers.get(name)
|
||
return state is not None and state.session is not None
|
||
|
||
return _poll_until(_live, timeout)
|
||
|
||
|
||
def _popen_mcp_server(script_path: Any, port: int) -> subprocess.Popen[bytes]:
|
||
"""Start a FastMCP live-server subprocess, streams to DEVNULL.
|
||
|
||
The shared spawn primitive for the live MCP smoke tests
|
||
(flaky-server flap loop, push-refresh) — the readiness wait and the
|
||
skip-vs-raise-on-failure policy legitimately differ per test and
|
||
stay at the call sites. ``sys.executable`` runs the same interpreter,
|
||
so a server-side import gap surfaces as a failed TCP wait, not here.
|
||
"""
|
||
return subprocess.Popen(
|
||
[sys.executable, str(script_path), str(port)],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
|
||
|
||
def make_oidc_test_config(**overrides: Any) -> OIDCConfig:
|
||
"""Build a test ``OIDCConfig`` with sensible defaults.
|
||
|
||
Shared between ``test_oidc.py`` and ``test_oidc_handlers.py`` so the
|
||
defaults (including the now-required ``redirect_base``) stay aligned.
|
||
"""
|
||
from turnstone.core.oidc import OIDCConfig
|
||
|
||
defaults: dict[str, Any] = {
|
||
"enabled": True,
|
||
"issuer": "https://idp.example.com",
|
||
"client_id": "my-client",
|
||
"client_secret": "my-secret",
|
||
"scopes": "openid email profile",
|
||
"provider_name": "TestIDP",
|
||
"role_claim": "",
|
||
"role_map": {},
|
||
"password_enabled": True,
|
||
"redirect_base": "https://app.example.com",
|
||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||
"token_endpoint": "https://idp.example.com/token",
|
||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||
}
|
||
defaults.update(overrides)
|
||
return OIDCConfig(**defaults)
|
||
|
||
|
||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||
parser.addoption(
|
||
"--storage-backend",
|
||
default="sqlite",
|
||
choices=["sqlite", "postgresql"],
|
||
help="Storage backend for integration tests (default: sqlite)",
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def tmp_db(tmp_path):
|
||
"""Provide a temporary SQLite storage backend (singleton registry)."""
|
||
from turnstone.core.storage import init_storage, reset_storage
|
||
|
||
db_path = str(tmp_path / "test.db")
|
||
reset_storage()
|
||
init_storage("sqlite", path=db_path, run_migrations=False)
|
||
yield db_path
|
||
reset_storage()
|
||
|
||
|
||
@pytest.fixture
|
||
def storage_backend(request, tmp_path):
|
||
"""Shared storage backend fixture — respects --storage-backend flag.
|
||
|
||
Returns a StorageBackend instance (SQLite or PostgreSQL).
|
||
Tests that use this fixture run against whichever backend CI selects.
|
||
"""
|
||
from turnstone.core.storage import init_storage, reset_storage
|
||
|
||
backend_type = request.config.getoption("--storage-backend")
|
||
reset_storage()
|
||
|
||
if backend_type == "postgresql":
|
||
pg_url = os.environ.get(
|
||
"TURNSTONE_TEST_PG_URL",
|
||
"postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test",
|
||
)
|
||
backend = init_storage("postgresql", url=pg_url, run_migrations=False)
|
||
yield backend
|
||
# Truncate all tables between tests — faster than DELETE and resets
|
||
# autoincrement sequences. CASCADE handles any future FK constraints.
|
||
# NOTE: accesses backend._engine (SQLAlchemy internal) — both SQLite
|
||
# and PostgreSQL backends expose this. If a non-SQLAlchemy backend is
|
||
# ever added, this cleanup will need a protocol-level hook.
|
||
try:
|
||
import sqlalchemy as sa
|
||
|
||
from turnstone.core.storage._schema import metadata as db_metadata
|
||
|
||
with backend._engine.connect() as conn:
|
||
table_names = ", ".join(t.name for t in reversed(db_metadata.sorted_tables))
|
||
conn.execute(sa.text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||
conn.commit()
|
||
except Exception:
|
||
pass # best-effort cleanup; reset_storage disposes engine
|
||
finally:
|
||
reset_storage()
|
||
else:
|
||
db_path = str(tmp_path / "test.db")
|
||
backend = init_storage("sqlite", path=db_path, run_migrations=False)
|
||
yield backend
|
||
reset_storage()
|
||
|
||
|
||
@pytest.fixture
|
||
def backend(storage_backend):
|
||
"""Alias for storage_backend — used by test_storage_sqlite.py etc."""
|
||
return storage_backend
|
||
|
||
|
||
@pytest.fixture
|
||
def db(storage_backend):
|
||
"""Alias for storage_backend — used by domain-specific storage tests."""
|
||
return storage_backend
|
||
|
||
|
||
@pytest.fixture
|
||
def storage(storage_backend):
|
||
"""Alias for storage_backend — used by services/skill resource tests."""
|
||
return storage_backend
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_openai_client():
|
||
"""Return a minimal mock OpenAI client."""
|
||
client = MagicMock()
|
||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||
return client
|
||
|
||
|
||
@pytest.fixture
|
||
def make_config_store():
|
||
"""Factory for a lightweight ConfigStore double.
|
||
|
||
``make_config_store(**overrides)`` returns an object whose ``.get(key)``
|
||
yields the override when present, else the registered SettingDef default —
|
||
mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads
|
||
as its ``False`` default on a miss, never ``None``). Shared by the
|
||
``server.require_project`` gate / advisory tests.
|
||
"""
|
||
|
||
_unset = object()
|
||
|
||
def _make(**overrides: Any) -> Any:
|
||
from turnstone.core.settings_registry import SETTINGS
|
||
|
||
class _ConfigStoreDouble:
|
||
def get(self, key: str, default: Any = _unset) -> Any:
|
||
# Mirror ConfigStore.get precedence exactly: cache (overrides)
|
||
# first, then a caller-supplied default, then the registry
|
||
# default, then None — so a reused caller passing an explicit
|
||
# default for an unset key gets the same value production would.
|
||
if key in overrides:
|
||
return overrides[key]
|
||
if default is not _unset:
|
||
return default
|
||
defn = SETTINGS.get(key)
|
||
return defn.default if defn else None
|
||
|
||
return _ConfigStoreDouble()
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_policy_cache():
|
||
"""Drop the in-process tool-policy cache between tests.
|
||
|
||
The cache is keyed by org_id (default ``""``), so without this
|
||
autouse hook a policy created in test A would leak into test B's
|
||
``evaluate_tool_policy`` call — distinct storage instances, same
|
||
cache slot. Production singleton storage doesn't see the leak
|
||
because there's only one storage instance for the process lifetime;
|
||
the test isolation requirement is what motivates the autouse.
|
||
"""
|
||
from turnstone.core.policy import invalidate_policy_cache
|
||
|
||
invalidate_policy_cache()
|
||
yield
|
||
invalidate_policy_cache()
|