Skip to content

Commit 4a73cad

Browse files
committed
test(server): cover WebSocket authentication and authorization
The WebSocket scope is now authenticated and authorized like HTTP, so update the route-authorization test that asserted WebSocket passthrough to instead verify that a permitted route connects and a non-permitted one is rejected with a close frame. Add AuthenticationMiddleware tests covering WebSocket handshakes with a missing token, an invalid token, and a valid token. Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent 556ba3c commit 4a73cad

2 files changed

Lines changed: 74 additions & 26 deletions

File tree

tests/unit/server/test_auth.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from unittest.mock import AsyncMock, Mock, patch
1111

1212
import pytest
13-
from fastapi import FastAPI
13+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
1414
from fastapi.testclient import TestClient
1515

1616
from ogx.core.datatypes import (
@@ -96,6 +96,32 @@ def http_client(http_app):
9696
return TestClient(http_app)
9797

9898

99+
@pytest.fixture
100+
def ws_app(mock_auth_endpoint):
101+
app = FastAPI()
102+
auth_config = AuthenticationConfig(
103+
provider_config=CustomAuthConfig(
104+
type=AuthProviderType.CUSTOM,
105+
endpoint=mock_auth_endpoint,
106+
),
107+
access_policy=[],
108+
)
109+
app.add_middleware(AuthenticationMiddleware, auth_config=auth_config, impls={})
110+
111+
@app.websocket("/ws")
112+
async def ws_endpoint(websocket: WebSocket):
113+
await websocket.accept()
114+
await websocket.send_text("authenticated")
115+
await websocket.close()
116+
117+
return app
118+
119+
120+
@pytest.fixture
121+
def ws_client(ws_app):
122+
return TestClient(ws_app)
123+
124+
99125
@pytest.fixture
100126
def mock_scope():
101127
return {
@@ -226,6 +252,26 @@ def test_http_auth_service_error(http_client, valid_api_key, suppress_auth_error
226252
assert "Authentication service error" in response.json()["error"]["message"]
227253

228254

255+
# WebSocket Endpoint Tests
256+
def test_websocket_missing_auth_header_rejected(ws_client, suppress_auth_errors):
257+
with pytest.raises(WebSocketDisconnect):
258+
with ws_client.websocket_connect("/ws") as websocket:
259+
websocket.receive_text()
260+
261+
262+
@patch("httpx.AsyncClient.post", new=mock_post_failure)
263+
def test_websocket_invalid_authentication_rejected(ws_client, invalid_api_key, suppress_auth_errors):
264+
with pytest.raises(WebSocketDisconnect):
265+
with ws_client.websocket_connect("/ws", headers={"Authorization": f"Bearer {invalid_api_key}"}) as websocket:
266+
websocket.receive_text()
267+
268+
269+
@patch("httpx.AsyncClient.post", new=mock_post_success)
270+
def test_websocket_valid_authentication_accepted(ws_client, valid_api_key):
271+
with ws_client.websocket_connect("/ws", headers={"Authorization": f"Bearer {valid_api_key}"}) as websocket:
272+
assert websocket.receive_text() == "authenticated"
273+
274+
229275
def test_http_auth_request_payload(http_client, valid_api_key, mock_auth_endpoint, suppress_auth_errors):
230276
with patch("httpx.AsyncClient.post") as mock_post:
231277
mock_response = MockResponse(200, {"message": "Authentication successful"})

tests/unit/server/test_route_auth.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -343,42 +343,44 @@ async def test_rule_order_matters(developer_user):
343343
assert not middleware._is_route_allowed("/v1/models/list", developer_user)
344344

345345

346-
async def test_websocket_passthrough():
347-
"""Test that websocket requests pass through without blocking"""
346+
async def test_websocket_route_authorization():
347+
"""WebSocket handshakes are subject to the same route policy as HTTP."""
348348
route_policy = [
349349
RouteAccessRule(
350-
permit=RouteScope(paths="/v1/chat/completions"),
351-
when="user with developer in roles",
350+
permit=RouteScope(paths="/v1/responses"),
351+
description="Allow the Responses WebSocket route",
352352
)
353+
# All other routes denied by default (no matching rule)
353354
]
354355

355-
app = FastAPI()
356-
middleware = RouteAuthorizationMiddleware(app, route_policy)
357-
358-
# Mock websocket scope
359-
scope = {
360-
"type": "websocket",
361-
"path": "/ws",
362-
}
363-
364-
# Track if next middleware was called
365-
called = False
366-
367-
async def mock_app(scope, receive, send):
368-
nonlocal called
369-
called = True
370-
371356
async def receive():
372357
return {}
373358

374-
async def send(msg):
375-
pass
359+
async def run(path: str) -> tuple[bool, list[dict]]:
360+
called = False
361+
sent: list[dict] = []
376362

377-
middleware.app = mock_app
378-
await middleware(scope, receive, send)
363+
async def mock_app(scope, receive, send):
364+
nonlocal called
365+
called = True
366+
367+
async def send(msg):
368+
sent.append(msg)
379369

380-
# Websocket requests should pass through
370+
middleware = RouteAuthorizationMiddleware(mock_app, route_policy)
371+
await middleware({"type": "websocket", "path": path}, receive, send)
372+
return called, sent
373+
374+
# Permitted route reaches the app.
375+
called, sent = await run("/v1/responses")
381376
assert called
377+
assert sent == []
378+
379+
# A route with no matching permit rule is rejected with a close frame
380+
# instead of being allowed through unauthenticated.
381+
called, sent = await run("/v1/admin/reset")
382+
assert not called
383+
assert sent == [{"type": "websocket.close", "code": 4403}]
382384

383385

384386
async def test_route_blocking_without_auth():

0 commit comments

Comments
 (0)