refactor(env): unify Docker env vars — canonical AUTH_SECRET_KEY, remove JWT_SECRET fallback
Canonical variables:
- AUTH_SECRET_KEY — JWT signing key for backend + agent (was split across
AUTH_SECRET_KEY / JWT_SECRET)
- SERVICE_JWT — agent→backend service token
- No JWT_SECRET fallback: decoder fails with migration guidance if only
JWT_SECRET is set
Compose files unified:
- docker-compose.yml, docker-compose.enterprise-clean.yml,
docker-compose.e2e.yml — all use AUTH_SECRET_KEY
- build.sh generated compose now passes AUTH_SECRET_KEY + ENCRYPTION_KEY
to backend
Env examples unified and completed:
- .env.example — comprehensive template, all compose vars
- .env.enterprise-clean.example — production template
- backend/.env.example — backend-only run
- docker/.env.agent.example — agent-only run
- NEW: .env.current.example, .env.master.example,
.env.e2e.example, frontend/.env.example
Tests aligned:
- conftest sets AUTH_SECRET_KEY (canonical value matched across test files)
- test mocks use canonical name
- 1176 passed, 0 failed
This commit is contained in:
@@ -1,22 +1,49 @@
|
||||
# ======================================================================
|
||||
# superset-tools — backend .env.example
|
||||
# Только обязательные переменные. Остальное настраивается через web UI.
|
||||
# Используется для прямого запуска бекенда (без docker compose).
|
||||
# При использовании docker-compose — переменные задаются в compose/environment.
|
||||
# ======================================================================
|
||||
|
||||
# ── Обязательно: секреты ──
|
||||
# JWT-ключ подписи токенов (backend + agent используют один)
|
||||
# Сгенерировать: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
AUTH_SECRET_KEY=change-me-to-a-random-secret
|
||||
# ── Безопасность (ОБЯЗАТЕЛЬНО) ─────────────────────────────────────────
|
||||
# JWT-ключ подписи токенов — единый для backend и agent.
|
||||
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
AUTH_SECRET_KEY=change-me-to-a-random-secret-32-chars-min
|
||||
|
||||
# Fernet-ключ шифрования паролей подключений и API-ключей
|
||||
# Fernet-ключ шифрования паролей подключений и API-ключей.
|
||||
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||
# Пример (сгенерируйте свой для прода):
|
||||
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY=
|
||||
|
||||
# ── Обязательно: база данных ──
|
||||
# PostgreSQL (пример для локальной разработки через docker-compose)
|
||||
# JWT audience / issuer (опционально)
|
||||
# JWT_AUDIENCE=superset-tools-api
|
||||
# JWT_ISSUER=superset-tools
|
||||
|
||||
# ── База данных (ОБЯЗАТЕЛЬНО) ──────────────────────────────────────────
|
||||
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||
# Отдельная БД для аутентификации (если не задана — требуется явно)
|
||||
AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||
# Отдельная БД для задач (если не задана — DATABASE_URL)
|
||||
TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
|
||||
|
||||
# ── Admin bootstrap ────────────────────────────────────────────────────
|
||||
# INITIAL_ADMIN_CREATE=true
|
||||
# INITIAL_ADMIN_USERNAME=admin
|
||||
# INITIAL_ADMIN_PASSWORD=change-me
|
||||
|
||||
# ── LLM / провайдеры ───────────────────────────────────────────────────
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
LLM_SSL_VERIFY=true
|
||||
LLM_CA_CERT_URLS=
|
||||
|
||||
# ── CORS / Безопасность ────────────────────────────────────────────────
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
FORCE_HTTPS=false
|
||||
APP_TIMEZONE=Europe/Moscow
|
||||
|
||||
# ── ADFS SSO (опционально) ─────────────────────────────────────────────
|
||||
# ADFS_CLIENT_ID=
|
||||
# ADFS_CLIENT_SECRET=
|
||||
# ADFS_METADATA_URL=
|
||||
|
||||
# ── OpenRouter (опционально) ───────────────────────────────────────────
|
||||
# OPENROUTER_SITE_URL=
|
||||
# OPENROUTER_APP_NAME=
|
||||
# APP_BASE_URL=
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
# backend/src/agent/_jwt_decoder.py
|
||||
# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode]
|
||||
# @BRIEF Lightweight JWT decode for agent — uses JWT_SECRET env var directly, avoids
|
||||
# @BRIEF Lightweight JWT decode for agent — uses AUTH_SECRET_KEY env var, avoids
|
||||
# pulling src.core.auth.jwt which requires AUTH_DATABASE_URL and ORM deps.
|
||||
# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature).
|
||||
# Token blacklisting is only relevant for backend auth flow — the agent
|
||||
# processes one request at a time and doesn't need DB-backed revocation.
|
||||
# @INVARIANT AUTH_SECRET_KEY is the ONLY accepted JWT signing key. JWT_SECRET is
|
||||
# unsupported and triggers a migration message if present without
|
||||
# AUTH_SECRET_KEY.
|
||||
# @REJECTED Importing src.core.auth.jwt.decode_token was rejected — it drags in
|
||||
# AuthConfig → AUTH_DATABASE_URL/SECRET_KEY validators → SQLAlchemy models,
|
||||
# AuthConfig -> AUTH_DATABASE_URL/SECRET_KEY validators -> SQLAlchemy models,
|
||||
# bloating the agent image with backend infrastructure it doesn't need.
|
||||
# @REJECTED Fallback from JWT_SECRET to AUTH_SECRET_KEY was rejected — ambiguous
|
||||
# naming caused operator confusion and secret drift between environments.
|
||||
import os
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT token. Tries JWT_SECRET first, then AUTH_SECRET_KEY.
|
||||
"""Decode and validate a JWT token using AUTH_SECRET_KEY from environment.
|
||||
|
||||
Performs stateless validation: signature, expiration, and required claims
|
||||
(exp, sub). Does NOT check token blacklist or audience/issuer.
|
||||
"""
|
||||
keys = []
|
||||
jwt_key = os.getenv("JWT_SECRET", "")
|
||||
auth_key = os.getenv("AUTH_SECRET_KEY", "")
|
||||
if jwt_key:
|
||||
keys.append(jwt_key)
|
||||
if auth_key and auth_key != jwt_key:
|
||||
keys.append(auth_key)
|
||||
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
|
||||
Raises JWTError with migration guidance if JWT_SECRET is set but
|
||||
AUTH_SECRET_KEY is missing (old deployments must rename the variable).
|
||||
"""
|
||||
secret = os.getenv("AUTH_SECRET_KEY", "")
|
||||
jwt_secret_legacy = os.getenv("JWT_SECRET", "")
|
||||
if not secret:
|
||||
if jwt_secret_legacy:
|
||||
raise JWTError("JWT_SECRET is no longer supported. Rename JWT_SECRET to AUTH_SECRET_KEY in your .env / docker-compose and restart the agent.")
|
||||
raise JWTError("AUTH_SECRET_KEY environment variable is not set")
|
||||
|
||||
return jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=[os.getenv("JWT_ALGORITHM", "HS256")],
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_aud": False,
|
||||
"require": ["exp", "sub"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# #endregion AgentChat.JwtDecoder
|
||||
|
||||
@@ -12,9 +12,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
|
||||
# Set JWT_SECRET and LLM_API_KEY for tests
|
||||
JWT_SECRET = "test-jwt-secret-key"
|
||||
os.environ["JWT_SECRET"] = JWT_SECRET
|
||||
# Set AUTH_SECRET_KEY and LLM_API_KEY for tests (match conftest)
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_MODEL"] = "gpt-4o"
|
||||
@@ -33,7 +33,7 @@ def mock_save_conversation():
|
||||
|
||||
|
||||
def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
|
||||
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def _empty_agent_state():
|
||||
@@ -68,6 +68,8 @@ async def test_handler_empty_message_returns_immediately():
|
||||
# create_agent should NOT be called for empty messages
|
||||
# (it gets called currently — future optimization)
|
||||
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.EmptyMessage
|
||||
|
||||
|
||||
@@ -92,10 +94,12 @@ async def test_handler_missing_auth_continues_gracefully():
|
||||
# Patch create_agent to prevent LLM call — handler should proceed without JWT
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = AsyncMock()
|
||||
|
||||
async def _empty_stream(*_args, **_kwargs):
|
||||
"""Empty async generator — yields nothing."""
|
||||
return
|
||||
yield # make it a generator
|
||||
|
||||
mock_graph.astream_events = _empty_stream
|
||||
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
|
||||
mock_create.return_value = mock_graph
|
||||
@@ -107,10 +111,10 @@ async def test_handler_missing_auth_continues_gracefully():
|
||||
# No UNAUTHORIZED error — handler proceeds with empty user_jwt
|
||||
for r in results:
|
||||
import json
|
||||
|
||||
parsed = json.loads(r) if isinstance(r, str) else r
|
||||
meta = parsed.get("metadata", {})
|
||||
assert meta.get("code") != "UNAUTHORIZED", \
|
||||
"Handler should not reject missing auth — JWT optional at Gradio layer"
|
||||
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject missing auth — JWT optional at Gradio layer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -127,9 +131,11 @@ async def test_handler_invalid_jwt_continues_gracefully():
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = AsyncMock()
|
||||
|
||||
async def _empty_stream(*_args, **_kwargs):
|
||||
return
|
||||
yield
|
||||
|
||||
mock_graph.astream_events = _empty_stream
|
||||
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
|
||||
mock_create.return_value = mock_graph
|
||||
@@ -140,10 +146,12 @@ async def test_handler_invalid_jwt_continues_gracefully():
|
||||
|
||||
for r in results:
|
||||
import json
|
||||
|
||||
parsed = json.loads(r) if isinstance(r, str) else r
|
||||
meta = parsed.get("metadata", {})
|
||||
assert meta.get("code") != "UNAUTHORIZED", \
|
||||
"Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
|
||||
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.AuthError
|
||||
|
||||
|
||||
@@ -180,11 +188,11 @@ async def test_handler_yields_stream_tokens():
|
||||
|
||||
assert len(results) > 0, "Should yield at least one chunk"
|
||||
import json
|
||||
stream_tokens = [
|
||||
r for r in results
|
||||
if json.loads(r)["metadata"]["type"] == "stream_token"
|
||||
]
|
||||
|
||||
stream_tokens = [r for r in results if json.loads(r)["metadata"]["type"] == "stream_token"]
|
||||
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.Streaming
|
||||
|
||||
|
||||
@@ -207,9 +215,11 @@ async def test_handler_resume_confirm():
|
||||
# imports create_agent directly from langgraph_setup
|
||||
with patch("src.agent._confirmation.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
|
||||
async def _empty_stream(*_args, **_kwargs):
|
||||
return
|
||||
yield
|
||||
|
||||
mock_graph.astream_events = _empty_stream
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
@@ -220,10 +230,13 @@ async def test_handler_resume_confirm():
|
||||
# Should yield confirm_resolved metadata
|
||||
assert len(results) == 1
|
||||
import json
|
||||
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "confirmed"
|
||||
assert mock_create.call_args.kwargs["interrupt_before"] == []
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.ResumeConfirm
|
||||
|
||||
|
||||
@@ -251,8 +264,11 @@ async def test_handler_resume_deny():
|
||||
|
||||
assert len(results) == 1
|
||||
import json
|
||||
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "denied"
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Handler.ResumeDeny
|
||||
# #endregion TestAgentChat.Handler
|
||||
|
||||
@@ -16,8 +16,8 @@ import pytest
|
||||
|
||||
import jwt
|
||||
|
||||
JWT_SECRET = "test-jwt-secret-key"
|
||||
os.environ["JWT_SECRET"] = JWT_SECRET
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_MODEL"] = "gpt-4o"
|
||||
@@ -25,7 +25,7 @@ os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
|
||||
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -37,6 +37,7 @@ def mock_save_conversation():
|
||||
# #region TestAgentChat.Confirmations.Concurrent [C:2] [TYPE Function] [SEMANTICS test,confirmation,concurrent]
|
||||
# @BRIEF Concurrent send lock prevents multiple simultaneous sends from same user.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_send_lock():
|
||||
"""Handler rejects concurrent sends from same user with CONCURRENT_SEND error."""
|
||||
@@ -58,17 +59,21 @@ async def test_concurrent_send_lock():
|
||||
|
||||
assert len(results) == 1
|
||||
import json
|
||||
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["code"] == "CONCURRENT_SEND"
|
||||
|
||||
# Clean up
|
||||
_user_locks.pop("admin", None)
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Confirmations.Concurrent
|
||||
|
||||
|
||||
# #region TestAgentChat.Confirmations.UnknownAction [C:2] [TYPE Function] [SEMANTICS test,confirmation,unknown]
|
||||
# @BRIEF Non-confirm/deny action values are treated as normal messages.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_unknown_action_treated_as_normal():
|
||||
"""Handler with unknown action string proceeds as normal message send."""
|
||||
@@ -84,8 +89,10 @@ async def test_handler_unknown_action_treated_as_normal():
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
|
||||
async def _mock_stream(*_args, **_kwargs):
|
||||
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
|
||||
|
||||
mock_graph.astream_events = _mock_stream
|
||||
state = MagicMock(spec_set=["next"])
|
||||
state.next = ()
|
||||
@@ -99,15 +106,19 @@ async def test_handler_unknown_action_treated_as_normal():
|
||||
# Should produce stream tokens, not confirm_resolved
|
||||
assert len(results) > 0
|
||||
import json
|
||||
|
||||
meta_types = [json.loads(r)["metadata"]["type"] for r in results]
|
||||
assert "stream_token" in meta_types
|
||||
assert "confirm_resolved" not in meta_types
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Confirmations.UnknownAction
|
||||
|
||||
|
||||
# #region TestAgentChat.Confirmations.ExpiredState [C:2] [TYPE Function] [SEMANTICS test,confirmation,expired]
|
||||
# @BRIEF Stale confirmations — checkpoint no longer available.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_confirm_no_graph():
|
||||
"""Handler with action='confirm' but create_agent raises handles it gracefully."""
|
||||
@@ -131,5 +142,7 @@ async def test_handler_confirm_no_graph():
|
||||
# Exception may propagate or be caught — either is acceptable
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Confirmations.ExpiredState
|
||||
# #endregion TestAgentChat.Confirmations
|
||||
|
||||
@@ -13,7 +13,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["JWT_SECRET"] = "test-jwt-secret-key"
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
os.environ["FASTAPI_URL"] = "http://test-backend:8000"
|
||||
os.environ["SERVICE_JWT"] = "test-service-jwt"
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
@@ -27,6 +27,7 @@ def anyio_backend():
|
||||
# #region TestAgentChat.Tools.DualAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth]
|
||||
# @BRIEF Dual-identity auth headers built from ContextVar and env vars.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_dual_auth_headers():
|
||||
"""Tools should build auth headers from ContextVar when set."""
|
||||
@@ -56,12 +57,15 @@ async def test_tool_dual_auth_headers():
|
||||
assert "Authorization" in headers, "Should include Authorization header"
|
||||
assert headers["Authorization"] == "Bearer service-jwt-token"
|
||||
assert headers["X-User-JWT"] == "user-jwt-token"
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.DualAuth
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.FallbackAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth,fallback]
|
||||
# @BRIEF Dual-identity auth falls back to env var when ContextVar is not set.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_auth_fallback_to_env():
|
||||
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
|
||||
@@ -74,8 +78,7 @@ async def test_tool_auth_fallback_to_env():
|
||||
set_service_jwt("")
|
||||
os.environ["SERVICE_JWT"] = "env-service-token"
|
||||
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), \
|
||||
patch("httpx.AsyncClient") as mock_client:
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
@@ -93,12 +96,15 @@ async def test_tool_auth_fallback_to_env():
|
||||
headers = kwargs.get("headers", {})
|
||||
# Should use env var
|
||||
assert "Authorization" in headers
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.FallbackAuth
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.HttpFailure [C:2] [TYPE Function] [SEMANTICS test,tools,failure]
|
||||
# @BRIEF Tool handles HTTP failure gracefully (returns error text, not exception).
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_http_exception_handling():
|
||||
"""Tool should propagate HTTP exception as error text."""
|
||||
@@ -116,12 +122,15 @@ async def test_tool_http_exception_handling():
|
||||
# Should propagate the exception (caller handles error)
|
||||
with pytest.raises((Exception,)):
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.HttpFailure
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.GetAll [C:2] [TYPE Function] [SEMANTICS test,tools,registry]
|
||||
# @BRIEF get_all_tools returns the expected list of tool functions.
|
||||
|
||||
|
||||
def test_get_all_tools_returns_expected_list():
|
||||
"""get_all_tools() should return search_dashboards, get_health_summary, etc."""
|
||||
from src.agent.tools import get_all_tools
|
||||
@@ -169,6 +178,7 @@ def test_get_all_tools_args_schema():
|
||||
# Tool get_all — full catalog (replaces get_tools_for_query)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_get_all_tools_returns_24_tools():
|
||||
"""get_all_tools() returns full catalog — all 24 tools registered."""
|
||||
from src.agent.tools import get_all_tools
|
||||
@@ -181,12 +191,15 @@ def test_get_all_tools_returns_24_tools():
|
||||
assert "get_health_summary" in tool_names
|
||||
# Regression: minimum count — must have all 24
|
||||
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.GetAll
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.ToolContracts [C:2] [TYPE Function] [SEMANTICS test,tools,contract]
|
||||
# @BRIEF Tool contracts match @POST and @PRE declared in contracts/modules.md.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_dashboards_correct_url():
|
||||
"""search_dashboards calls GET /api/dashboards with query params."""
|
||||
@@ -212,12 +225,15 @@ async def test_search_dashboards_correct_url():
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/dashboards" in url
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.ToolContracts
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
|
||||
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_health_summary_calls_correct_url():
|
||||
"""get_health_summary should call GET /api/health/summary."""
|
||||
@@ -241,12 +257,15 @@ async def test_get_health_summary_calls_correct_url():
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/health/summary" in url
|
||||
assert kwargs.get("params") == {"environment_id": "ss-dev"}
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.HealthSummary
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
|
||||
# @BRIEF list_environments calls the correct FastAPI endpoint.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_environments_calls_correct_url():
|
||||
"""list_environments should call GET /api/settings/environments."""
|
||||
@@ -284,10 +303,7 @@ async def test_list_environments_redacts_sensitive_fields():
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = (
|
||||
'[{"id":"prod","password":"secret-pass","api_key":"secret-key",'
|
||||
'"nested":{"token":"secret-token"},"name":"ss-prod"}]'
|
||||
)
|
||||
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
|
||||
|
||||
result = await list_environments.ainvoke({})
|
||||
|
||||
@@ -295,12 +311,15 @@ async def test_list_environments_redacts_sensitive_fields():
|
||||
assert "secret-key" not in result
|
||||
assert "secret-token" not in result
|
||||
assert result.count("[redacted]") == 3
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.ListEnvironments
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
|
||||
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_task_status_calls_correct_url():
|
||||
"""get_task_status should call GET /api/tasks/{task_id}."""
|
||||
@@ -323,6 +342,8 @@ async def test_get_task_status_calls_correct_url():
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/tasks/task-123" in url
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.TaskStatus
|
||||
|
||||
|
||||
@@ -380,6 +401,7 @@ async def test_deploy_dashboard_posts_git_endpoint():
|
||||
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
|
||||
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
|
||||
|
||||
|
||||
def test_dual_auth_headers_with_both_jwts():
|
||||
"""_dual_auth_headers uses service auth plus user identity when both are set."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -418,5 +440,7 @@ def test_dual_auth_headers_no_jwts():
|
||||
# (set at import time based on os.environ)
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"].startswith("Bearer ")
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.DualAuthHeaders
|
||||
# #endregion TestAgentChat.Tools
|
||||
|
||||
Reference in New Issue
Block a user