Files
ss-tools/backend/tests/test_app_ws_auth.py
busya a1cb18fad9 fix: stop WS reconnect storm on auth rejection; map 502/503/504 to NetworkError
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.
2026-08-01 13:23:30 +07:00

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