WebSocket endpoints now accept then close with real codes (4001 auth, 4003 permission) so clients detect auth failure via event.code instead of an opaque 403 handshake, ending the infinite reconnect storm. _authenticate_websocket logs the actual JWT/API-key failure reason. Frontend WS consumers stop on auth rejection and use capped exponential backoff for transient failures. async_network.request() routes proxy 502/503/504 (HTML) responses to NetworkError so migration/maintenance surface a clean 503 instead of a 500 JSON-parse traceback.
184 lines
8.6 KiB
Python
184 lines
8.6 KiB
Python
# #region Test.AppModule.WsAuth [C:3] [TYPE Module] [SEMANTICS test,app,ws,auth,jwt,apikey]
|
|
# @BRIEF Tests for app.py — _authenticate_websocket: JWT/API key auth for WebSocket connections.
|
|
# @RELATION BINDS_TO -> [App.AppModule]
|
|
# @TEST_EDGE: ws_missing_token -> returns False
|
|
# @TEST_EDGE: ws_jwt_auth -> accepts valid JWT
|
|
# @TEST_EDGE: ws_jwt_fallback_apikey -> accepts valid API key after JWT fail
|
|
# @TEST_EDGE: ws_both_fail -> returns False when both auth methods fail
|
|
# @TEST_INVARIANT: ws_auth_gate -> VERIFIED_BY: test_websocket_auth_missing_token, test_websocket_auth_jwt_valid, test_websocket_auth_api_key
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
class TestAuthenticateWebsocket:
|
|
"""_authenticate_websocket — JWT/API key auth."""
|
|
|
|
# #region Test.AppModule.TestMissingToken [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_missing_token(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {}
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
# #endregion Test.AppModule.TestMissingToken
|
|
|
|
# #region Test.AppModule.TestJwtValid [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_valid(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid.jwt"}
|
|
with patch("src.core.auth.jwt.decode_token", return_value={"sub": "testuser"}) as md:
|
|
assert await _authenticate_websocket(ws, "ws/logs") is True
|
|
md.assert_called_once_with("valid.jwt")
|
|
# #endregion Test.AppModule.TestJwtValid
|
|
|
|
# #region Test.AppModule.TestJwtFailureLogsReason [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_failure_logs_reason(self):
|
|
"""On JWT decode failure the actual exception reason is surfaced in the log payload."""
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "expired.jwt"}
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", side_effect=Exception("Signature has expired")),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal", return_value=MagicMock()) as msl,
|
|
patch("src.app.logger.explore") as mexplore,
|
|
):
|
|
msl.return_value.query.return_value.filter.return_value.first.return_value = None
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
reasons = [c.kwargs.get("payload", {}).get("reason") or c[1].get("payload", {}).get("reason")
|
|
for c in mexplore.call_args_list]
|
|
assert any("Signature has expired" in (r or "") for r in reasons)
|
|
# #endregion Test.AppModule.TestJwtFailureLogsReason
|
|
|
|
# #region Test.AppModule.TestJwtNoSub [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_no_sub(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "no-sub"}
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", return_value={"role": "admin"}),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.return_value.query.return_value.filter.return_value.first.return_value = None
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
# #endregion Test.AppModule.TestJwtNoSub
|
|
|
|
# #region Test.AppModule.TestJwtFallbackApikey [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_fallback_apikey(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid-key"}
|
|
key = MagicMock(); key.active = True; key.name = "Test"
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", side_effect=Exception("expired")),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.return_value.query.return_value.filter.return_value.first.return_value = key
|
|
assert await _authenticate_websocket(ws, "ws/logs") is True
|
|
# #endregion Test.AppModule.TestJwtFallbackApikey
|
|
|
|
# #region Test.AppModule.TestApikeyInactive [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_apikey_inactive(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "inactive"}
|
|
key = MagicMock(); key.active = False
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", side_effect=Exception("fail")),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.return_value.query.return_value.filter.return_value.first.return_value = key
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
# #endregion Test.AppModule.TestApikeyInactive
|
|
|
|
# #region Test.AppModule.TestBothFail [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_both_fail(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "bad"}
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", side_effect=Exception("invalid")),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.return_value.query.return_value.filter.return_value.first.return_value = None
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
# #endregion Test.AppModule.TestBothFail
|
|
|
|
|
|
class TestAuthenticateWebsocketApiKeyException:
|
|
"""_authenticate_websocket — API key DB exception is caught."""
|
|
|
|
# #region Test.AppModule.TestApikeyDbException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_apikey_db_exception(self):
|
|
from src.app import _authenticate_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "crash"}
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", side_effect=Exception("JWT fail")),
|
|
patch("src.core.auth.api_key.hash_api_key", return_value="hk"),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
):
|
|
db = MagicMock(); db.query.side_effect = Exception("DB lost")
|
|
msl.return_value = db
|
|
assert await _authenticate_websocket(ws, "ws/logs") is False
|
|
# #endregion Test.AppModule.TestApikeyDbException
|
|
|
|
|
|
# #region Test.AppModule.AuthorizeWebsocket [C:3] [TYPE Class] [SEMANTICS test,app,ws,authorization,rbac]
|
|
# @BRIEF Verify WebSocket authorization grants access only through is_admin or explicit permissions.
|
|
# @RELATION BINDS_TO -> [App.AppModule.AuthorizeWebsocket]
|
|
class TestAuthorizeWebsocket:
|
|
def _authorize(self, user, resource="tasks", action="READ"):
|
|
from src.app import _authorize_websocket
|
|
|
|
ws = MagicMock()
|
|
ws.query_params = {"token": "valid.jwt"}
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = user
|
|
with (
|
|
patch("src.core.auth.jwt.decode_token", return_value={"sub": "testuser"}),
|
|
patch("src.core.database.SessionLocal", return_value=db),
|
|
):
|
|
result = _authorize_websocket(ws, resource, action)
|
|
db.close.assert_called_once()
|
|
return result
|
|
|
|
def test_is_admin_role_bypasses_permission_check(self):
|
|
user = SimpleNamespace(
|
|
is_active=True,
|
|
roles=[SimpleNamespace(name="Admin", is_admin=True, permissions=[])],
|
|
)
|
|
assert self._authorize(user) is True
|
|
|
|
def test_legacy_admin_name_without_flag_does_not_bypass_permissions(self):
|
|
user = SimpleNamespace(
|
|
is_active=True,
|
|
roles=[SimpleNamespace(name="Admin", is_admin=False, permissions=[])],
|
|
)
|
|
assert self._authorize(user) is False
|
|
|
|
def test_explicit_permission_allows_websocket_access(self):
|
|
permission = SimpleNamespace(resource="tasks", action="READ")
|
|
user = SimpleNamespace(
|
|
is_active=True,
|
|
roles=[SimpleNamespace(name="Operator", is_admin=False, permissions=[permission])],
|
|
)
|
|
assert self._authorize(user) is True
|
|
# #endregion Test.AppModule.AuthorizeWebsocket
|
|
# #endregion Test.AppModule.WsAuth
|