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:
2026-07-06 14:24:17 +03:00
parent a5b7adb61c
commit 48e3ff4503
15 changed files with 586 additions and 126 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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