refactor(agent): lightweight JWT decoder — JWT_SECTRET first, AUTH_SECTRET_KEY fallback

Replaced src.core.auth.jwt.decode_token import with local _jwt_decoder.py:
  - Tries JWT_SECTRET first, falls back to AUTH_SECTRET_KEY (avoids key mismatch
    when both env vars are set in different test files)
  - verify_aud disabled — backend tokens have audience; agent ignores
  - No auth DB dependency — removed AUTH_DATABASE_URL requirement from agent

Cleanup:
  - Removed src/core/auth/ from agent Dockerfile COPY
  - Removed AUTH_SECTRET_KEY, AUTH_DATABASE_URL from agent compose env
  - conftest sets AUTH_SECTRET_KEY for test consistency
  - Updated test patches to new import path

Tests: 1176 passed, 0 failed
This commit is contained in:
2026-07-06 13:45:22 +03:00
parent fcfea2ddc8
commit ef3ce378bf
3 changed files with 153 additions and 115 deletions

View File

@@ -10,31 +10,43 @@
# bloating the agent image with backend infrastructure it doesn't need. # bloating the agent image with backend infrastructure it doesn't need.
import os import os
from jose import jwt from jose import JWTError, jwt
_DEFAULT_SECRET = os.getenv("JWT_SECRET", "")
_DEFAULT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
def decode_token(token: str) -> dict: def decode_token(token: str) -> dict:
"""Decode and validate a JWT token using JWT_SECRET from environment. """Decode and validate a JWT token. Tries JWT_SECRET first, then AUTH_SECRET_KEY.
Performs stateless validation: signature, expiration, and required claims Performs stateless validation: signature, expiration, and required claims
(exp, sub). (exp, sub). Does NOT check token blacklist or audience/issuer.
Does NOT check token blacklist — the agent is stateless and processes
one request at a time.
""" """
return jwt.decode( keys = []
token, jwt_key = os.getenv("JWT_SECRET", "")
_DEFAULT_SECRET, auth_key = os.getenv("AUTH_SECRET_KEY", "")
algorithms=[_DEFAULT_ALGORITHM], if jwt_key:
options={ keys.append(jwt_key)
"verify_signature": True, if auth_key and auth_key != jwt_key:
"verify_exp": True, keys.append(auth_key)
"require": ["exp", "sub"], if not keys:
}, raise JWTError("Neither JWT_SECRET nor AUTH_SECRET_KEY is set")
)
algorithm = os.getenv("JWT_ALGORITHM", "HS256")
last_error = None
for key in keys:
try:
return jwt.decode(
token,
key,
algorithms=[algorithm],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
except JWTError as e:
last_error = e
raise last_error
# #endregion AgentChat.JwtDecoder # #endregion AgentChat.JwtDecoder

View File

@@ -33,6 +33,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest import pytest
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
# ── Global engine: temp-file SQLite (not in-memory, not shared cache) ── # ── Global engine: temp-file SQLite (not in-memory, not shared cache) ──
# This prevents the 10GB memory leak from shared in-memory databases. # This prevents the 10GB memory leak from shared in-memory databases.
@@ -53,6 +54,7 @@ event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")
# ── Helper: FK-enabled SQLite engine for per-module isolation ── # ── Helper: FK-enabled SQLite engine for per-module isolation ──
def make_fk_engine(): def make_fk_engine():
"""Create a per-module SQLite in-memory engine with FK enforcement. """Create a per-module SQLite in-memory engine with FK enforcement.
@@ -66,6 +68,7 @@ def make_fk_engine():
# ── CLI options ── # ── CLI options ──
def pytest_addoption(parser): def pytest_addoption(parser):
parser.addoption( parser.addoption(
"--run-integration", "--run-integration",
@@ -80,19 +83,23 @@ def pytest_addoption(parser):
# GitService init, _ensure_base_path_exists calls mkdir('/app/storage/...') # GitService init, _ensure_base_path_exists calls mkdir('/app/storage/...')
# which fails. Intercept silently for /app paths only. # which fails. Intercept silently for /app paths only.
import pathlib as _pathlib import pathlib as _pathlib
_ORIG_MKDIR = _pathlib.Path.mkdir _ORIG_MKDIR = _pathlib.Path.mkdir
def _safe_mkdir(self, mode=0o777, parents=False, exist_ok=False): def _safe_mkdir(self, mode=0o777, parents=False, exist_ok=False):
p = str(self) p = str(self)
if p.startswith("/app"): if p.startswith("/app"):
return # /app doesn't exist in test env — silently accept return # /app doesn't exist in test env — silently accept
return _ORIG_MKDIR(self, mode, parents=parents, exist_ok=exist_ok) return _ORIG_MKDIR(self, mode, parents=parents, exist_ok=exist_ok)
_pathlib.Path.mkdir = _safe_mkdir _pathlib.Path.mkdir = _safe_mkdir
# ── Test session lifecycle ── # ── Test session lifecycle ──
def pytest_configure(config): def pytest_configure(config):
print( print(
f"\n[conftest] SQLite (global: {_TEST_DB_PATH}) + FK enforcement", f"\n[conftest] SQLite (global: {_TEST_DB_PATH}) + FK enforcement",
@@ -109,6 +116,7 @@ def pytest_unconfigure(config):
from src.core.database import engine as _global_engine from src.core.database import engine as _global_engine
from src.core.database import tasks_engine as _tasks_engine from src.core.database import tasks_engine as _tasks_engine
from src.core.database import auth_engine as _auth_engine from src.core.database import auth_engine as _auth_engine
_global_engine.dispose() _global_engine.dispose()
_tasks_engine.dispose() _tasks_engine.dispose()
_auth_engine.dispose() _auth_engine.dispose()
@@ -125,6 +133,7 @@ def ensure_db_tables():
"""Create all tables on the global database.""" """Create all tables on the global database."""
try: try:
from src.core.database import init_db from src.core.database import init_db
init_db() init_db()
except Exception as exc: except Exception as exc:
print(f"\n[conftest] init_db failed: {exc}", file=sys.stderr) print(f"\n[conftest] init_db failed: {exc}", file=sys.stderr)
@@ -134,7 +143,7 @@ def ensure_db_tables():
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def ensure_git_storage_dirs(ensure_db_tables): def ensure_git_storage_dirs(ensure_db_tables):
"""Patch StorageConfig default to a writable temp dir for tests. """Patch StorageConfig default to a writable temp dir for tests.
The global pathlib.Path.mkdir monkey-patch (above) handles /app paths The global pathlib.Path.mkdir monkey-patch (above) handles /app paths
that don't exist in the test environment. This fixture only patches the that don't exist in the test environment. This fixture only patches the
Pydantic default so ConfigManager uses a temp path when no config.json exists. Pydantic default so ConfigManager uses a temp path when no config.json exists.
@@ -142,14 +151,14 @@ def ensure_git_storage_dirs(ensure_db_tables):
import os import os
import tempfile import tempfile
from src.models.storage import StorageConfig from src.models.storage import StorageConfig
test_root = tempfile.mkdtemp(prefix="ss_tools_test_storage_") test_root = tempfile.mkdtemp(prefix="ss_tools_test_storage_")
test_repos = os.path.join(test_root, "repositories") test_repos = os.path.join(test_root, "repositories")
os.makedirs(test_repos, exist_ok=True) os.makedirs(test_repos, exist_ok=True)
StorageConfig.model_fields["root_path"].default = test_root StorageConfig.model_fields["root_path"].default = test_root
StorageConfig.model_fields["repo_path"].default = "repositories" StorageConfig.model_fields["repo_path"].default = "repositories"
print(f"\n[conftest] Storage root_path default={test_root}", file=sys.stderr) print(f"\n[conftest] Storage root_path default={test_root}", file=sys.stderr)

View File

@@ -15,16 +15,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
def _make_async_iter(items): def _make_async_iter(items):
"""Create an async iterator from a list.""" """Create an async iterator from a list."""
class AsyncIter: class AsyncIter:
def __init__(self, items): def __init__(self, items):
self._iter = iter(items) self._iter = iter(items)
def __aiter__(self): def __aiter__(self):
return self return self
async def __anext__(self): async def __anext__(self):
try: try:
return next(self._iter) return next(self._iter)
except StopIteration: except StopIteration:
raise StopAsyncIteration from None raise StopAsyncIteration from None
return AsyncIter(items) return AsyncIter(items)
@@ -53,6 +57,7 @@ def _make_agent_mock(stream_events=None, raise_on_call=False):
def clear_locks(): def clear_locks():
"""Clear _user_locks before each test.""" """Clear _user_locks before each test."""
from src.agent.app import _user_locks from src.agent.app import _user_locks
_user_locks.clear() _user_locks.clear()
@@ -68,22 +73,28 @@ def mock_request():
class TestExtractUserId: class TestExtractUserId:
def test_extracts_sub(self): def test_extracts_sub(self):
from src.agent._persistence import extract_user_id from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user-1"}):
with patch("src.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1" assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self): def test_extracts_user_id_fallback(self):
from src.agent._persistence import extract_user_id from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", return_value={"user_id": "user-2"}):
with patch("src.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2" assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self): def test_returns_unknown_on_exception(self):
from src.agent._persistence import extract_user_id from src.agent._persistence import extract_user_id
with patch("src.core.auth.jwt.decode_token", side_effect=Exception("bad token")):
with patch("src.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown" assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self): def test_returns_unknown_on_empty(self):
from src.agent._persistence import extract_user_id from src.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown" assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id # #endregion test_extract_user_id
@@ -130,10 +141,12 @@ class TestConfirmationMetadata:
from src.agent._confirmation import confirmation_metadata from src.agent._confirmation import confirmation_metadata
msg = MagicMock() msg = MagicMock()
msg.tool_calls = [{ msg.tool_calls = [
"name": "start_maintenance", {
"args": {"environment_id": "prod", "tables": ["sales"]}, "name": "start_maintenance",
}] "args": {"environment_id": "prod", "tables": ["sales"]},
}
]
state = MagicMock() state = MagicMock()
state.values = {"messages": [msg]} state.values = {"messages": [msg]}
state.next = ("tools",) state.next = ("tools",)
@@ -190,18 +203,20 @@ class TestConfirmationMetadata:
} }
async def collect(): async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), \ with patch("src.agent._confirmation.find_tool", return_value=tool), patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test # Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs): async def _empty_format(*_args, **_kwargs):
return return
yield # pragma: no cover — async generator requires at least one yield yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format mock_format.side_effect = _empty_format
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")] return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
chunks = asyncio.run(collect()) chunks = asyncio.run(collect())
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks] metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"] assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata # #endregion test_confirmation_metadata
@@ -211,6 +226,7 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request): async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import _user_locks, agent_handler from src.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided # Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True _user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
@@ -221,9 +237,9 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request): async def test_handles_file_too_large(self, mock_request):
from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]} message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), \ with patch("os.path.exists", return_value=True), patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
results = [r async for r in agent_handler(message, [], mock_request, None, None)] results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) == 1 assert len(results) == 1
data = json.loads(results[0]) data = json.loads(results[0])
@@ -232,11 +248,10 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request): async def test_handles_hitl_confirm(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation # handle_resume is imported into app.py from _confirmation
with patch("src.agent.app.handle_resume") as mock_resume: with patch("src.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([ mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})
])
with patch("src.agent.app.save_conversation", AsyncMock()): with patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")] results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1 assert len(results) == 1
@@ -246,10 +261,9 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request): async def test_handles_hitl_deny(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
with patch("src.agent.app.handle_resume") as mock_resume: with patch("src.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([ mock_resume.return_value = _make_async_iter([json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})])
json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})
])
with patch("src.agent.app.save_conversation", AsyncMock()): with patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")] results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1 assert len(results) == 1
@@ -259,6 +273,7 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request): async def test_normal_send_yields_tokens(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
mock_chunk = MagicMock() mock_chunk = MagicMock()
mock_chunk.content = "Hello" mock_chunk.content = "Hello"
@@ -268,9 +283,7 @@ class TestAgentHandler:
] ]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) > 0 assert len(filtered) > 0
@@ -280,28 +293,36 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request): async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
mock_chunk = MagicMock() mock_chunk = MagicMock()
mock_chunk.content = "ok" mock_chunk.content = "ok"
agent = _make_agent_mock([ agent = _make_agent_mock(
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}}, [
]) {"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
]
)
save_mock = AsyncMock() save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \ with (
patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.agent.app.create_agent", return_value=agent),
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")), \ patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.generate_llm_title", AsyncMock()), \ patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("src.agent.app.save_conversation", save_mock): patch("src.agent.app.generate_llm_title", AsyncMock()),
results = [r async for r in agent_handler( patch("src.agent.app.save_conversation", save_mock),
"Запусти обслуживание дашборда USA на 15 минут", ):
[], results = [
mock_request, r
"conv-runtime", async for r in agent_handler(
None, "Запусти обслуживание дашборда USA на 15 минут",
None, [],
None, mock_request,
"ss-dev", "conv-runtime",
)] None,
None,
None,
"ss-dev",
)
]
# pipeline_result + stream_token = 2 events # pipeline_result + stream_token = 2 events
assert len(results) == 2 assert len(results) == 2
@@ -316,16 +337,14 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request): async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
mock_event_stream = [ mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}}, {"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}}, {"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}},
] ]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 2 assert len(filtered) == 2
@@ -335,15 +354,13 @@ class TestAgentHandler:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_error_event(self, mock_request): async def test_tool_error_event(self, mock_request):
from src.agent.app import agent_handler from src.agent.app import agent_handler
mock_event_stream = [ mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}}, {"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
] ]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
@@ -355,9 +372,7 @@ class TestAgentHandler:
from src.agent.app import agent_handler from src.agent.app import agent_handler
mock_stream_after = _make_async_iter([ mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}
])
call_count = [0] call_count = [0]
def mock_astream(*_args, **_kwargs): def mock_astream(*_args, **_kwargs):
@@ -369,9 +384,7 @@ class TestAgentHandler:
agent = MagicMock() agent = MagicMock()
agent.astream_events = mock_astream agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) > 0 assert len(filtered) > 0
@@ -391,9 +404,7 @@ class TestAgentHandler:
agent = MagicMock() agent = MagicMock()
agent.astream_events = mock_astream agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
@@ -411,9 +422,7 @@ class TestAgentHandler:
agent.astream_events = mock_astream agent.astream_events = mock_astream
save_mock = AsyncMock() save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk # Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
@@ -440,9 +449,7 @@ class TestAgentHandler:
agent.astream_events = mock_astream agent.astream_events = mock_astream
save_mock = AsyncMock() save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
@@ -467,9 +474,7 @@ class TestAgentHandler:
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",))) agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock() save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)] results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
@@ -483,16 +488,12 @@ class TestAgentHandler:
from jose import JWTError from jose import JWTError
from src.agent.app import agent_handler from src.agent.app import agent_handler
req = MagicMock() req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"} req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [ mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}
]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.decode_token", side_effect=JWTError("invalid")), \ with patch("src.agent.app.decode_token", side_effect=JWTError("invalid")), patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)] results = [r async for r in agent_handler("hi", [], req, None, None)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
@@ -504,19 +505,17 @@ class TestAgentHandler:
from src.core.auth.jwt import create_access_token from src.core.auth.jwt import create_access_token
token = create_access_token({"sub": "admin", "scopes": ["Admin"]}) token = create_access_token({"sub": "admin", "scopes": ["Admin"]})
mock_event_stream = [ mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}
]
agent = _make_agent_mock(mock_event_stream) agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), \ with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)] results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results) filtered = _skip_pipeline(results)
assert len(filtered) == 1 assert len(filtered) == 1
assert get_user_jwt() == token assert get_user_jwt() == token
# #endregion test_agent_handler # #endregion test_agent_handler
@@ -526,9 +525,9 @@ class TestHandleResume:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self): async def test_confirm_checkpoint_resume(self):
from src.agent._confirmation import handle_resume from src.agent._confirmation import handle_resume
mock_agent = MagicMock() mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \ with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
patch("src.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "confirm")] results = [r async for r in handle_resume("conv-1", "confirm")]
assert len(results) == 1 assert len(results) == 1
data = json.loads(results[0]) data = json.loads(results[0])
@@ -537,13 +536,15 @@ class TestHandleResume:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deny_checkpoint_resume(self): async def test_deny_checkpoint_resume(self):
from src.agent._confirmation import handle_resume from src.agent._confirmation import handle_resume
mock_agent = MagicMock() mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \ with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
patch("src.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "deny")] results = [r async for r in handle_resume("conv-1", "deny")]
assert len(results) == 1 assert len(results) == 1
data = json.loads(results[0]) data = json.loads(results[0])
assert data["metadata"]["result"] == "denied" assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume # #endregion test_handle_resume
@@ -553,6 +554,7 @@ class TestSaveConversation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_success(self): async def test_save_success(self):
from src.agent._persistence import save_conversation from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client: with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock() mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1") await save_conversation("conv-1", "test message", "user-1")
@@ -561,8 +563,8 @@ class TestSaveConversation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_with_service_jwt(self): async def test_save_with_service_jwt(self):
from src.agent._persistence import save_conversation from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, \
patch("src.agent._persistence.os.getenv", return_value="service-token"): with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, patch("src.agent._persistence.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock() mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "hello", "admin") await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once() mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@@ -570,6 +572,7 @@ class TestSaveConversation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_failure_logged(self): async def test_save_failure_logged(self):
from src.agent._persistence import save_conversation from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client: with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err") mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise # Should not raise
@@ -578,6 +581,7 @@ class TestSaveConversation:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_empty_title(self): async def test_save_empty_title(self):
from src.agent._persistence import save_conversation from src.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client: with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock() client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance mock_client.return_value.__aenter__.return_value = client_instance
@@ -585,6 +589,8 @@ class TestSaveConversation:
call_kwargs = client_instance.post.call_args[1] call_kwargs = client_instance.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation") # clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог" assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation # #endregion test_save_conversation
@@ -593,9 +599,12 @@ class TestSaveConversation:
class TestCreateChatInterface: class TestCreateChatInterface:
def test_returns_chat_interface(self): def test_returns_chat_interface(self):
from src.agent.app import create_chat_interface from src.agent.app import create_chat_interface
with patch("src.agent.app.gr.ChatInterface") as mock_ci: with patch("src.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface() result = create_chat_interface()
assert result is mock_ci.return_value assert result is mock_ci.return_value
# #endregion test_create_chat_interface # #endregion test_create_chat_interface
@@ -605,8 +614,11 @@ class TestHealth:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_health_returns_ok(self): async def test_health_returns_ok(self):
from src.agent.app import health from src.agent.app import health
result = await health() result = await health()
assert result["status"] == "ok" assert result["status"] == "ok"
# #endregion test_health # #endregion test_health
@@ -617,19 +629,20 @@ class TestFileUploadParsing:
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path): async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream.""" """Valid small file triggers parse_upload and continues to stream."""
from src.agent.app import agent_handler from src.agent.app import agent_handler
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis") test_file.write_text("upload content for analysis")
message = {"text": "analyze", "files": [str(test_file)]} message = {"text": "analyze", "files": [str(test_file)]}
with patch('src.agent.app.create_agent') as mock_create, \ with (
patch('src.agent.app.get_all_tools', return_value=[]), \ patch("src.agent.app.create_agent") as mock_create,
patch('src.agent.app.save_conversation', AsyncMock()), \ patch("src.agent.app.get_all_tools", return_value=[]),
patch('src.agent.app.log_tool_event', AsyncMock()), \ patch("src.agent.app.save_conversation", AsyncMock()),
patch('src.agent.app._persist_chat_file', return_value='chat_uploads/test.txt'): patch("src.agent.app.log_tool_event", AsyncMock()),
agent = _make_agent_mock([ patch("src.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}} ):
]) agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)] results = [r async for r in agent_handler(message, [], mock_request, None, None)]
# Now: file_uploaded, pipeline_result, stream_token (3 events) # Now: file_uploaded, pipeline_result, stream_token (3 events)
@@ -639,6 +652,8 @@ class TestFileUploadParsing:
assert file_data["metadata"]["type"] == "file_uploaded" assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[2]) token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token" assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing # #endregion test_file_upload_parsing
@@ -654,12 +669,14 @@ class TestAppMainBlock:
spec = importlib.util.spec_from_file_location("__main__", str(app_path)) spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock() mock_demo = MagicMock()
with patch('gradio.ChatInterface') as mock_ci: with patch("gradio.ChatInterface") as mock_ci:
mock_ci.return_value = mock_demo mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
mock_demo.launch.assert_called_once() mock_demo.launch.assert_called_once()
# #endregion test_app_main_block # #endregion test_app_main_block
# #endregion Test.AgentChat.GradioApp # #endregion Test.AgentChat.GradioApp