from datetime import UTC, datetime, timedelta import jwt import pytest from fastapi import HTTPException, Request, Response, status from monday_com_orca_backend.api.auth.enums import PayloadKey from monday_com_orca_backend.api.auth.main import VALID_TOKEN_CACHE, verify_token from monday_com_orca_backend.enums import HttpHeader, HttpMethod class TestJWTMiddleware: @pytest.fixture def dummy_app(self): async def app(scope, receive, send): response = Response("OK") await response(scope, receive, send) return app @pytest.fixture def dummy_call_next(self): async def call_next(_): return Response("next called") return call_next @pytest.fixture def make_request(self, mocker): def _make(path="/protected", method="GET"): req = mocker.MagicMock() req.url.path = path req.method = method req.headers = {} req.state = mocker.MagicMock() return req return _make @pytest.mark.asyncio @pytest.mark.parametrize( "excluded_paths, path, should_skip", [(["/excluded"], "/excluded", True), (["/excluded"], "/not-excluded", False)], ) async def test_excluded_path( self, dummy_app, dummy_call_next, make_request, excluded_paths, path, should_skip, monkeypatch, ): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app, excluded_paths=excluded_paths) req = make_request(path=path) called = False async def call_next_wrapper(request): nonlocal called called = True return await dummy_call_next(request) if should_skip: response = await middleware.dispatch(req, call_next_wrapper) assert called assert response.body == b"next called" else: import monday_com_orca_backend.api.auth.main as main_mod called_verify = False async def fake_verify_token(request): nonlocal called_verify called_verify = True monkeypatch.setattr(main_mod, "verify_token", fake_verify_token) response = await middleware.dispatch(req, call_next_wrapper) assert called_verify assert response.body == b"next called" @pytest.mark.asyncio @pytest.mark.parametrize( "excluded_prefixes, path, should_skip", [(["/api/"], "/api/anything", True), (["/api/"], "/other", False)], ) async def test_excluded_prefix( self, dummy_app, dummy_call_next, make_request, excluded_prefixes, path, should_skip, mocker, ): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app, excluded_prefixes=excluded_prefixes) req = make_request(path=path) called = False async def call_next_wrapper(request): nonlocal called called = True return await dummy_call_next(request) if should_skip: response = await middleware.dispatch(req, call_next_wrapper) assert called assert response.body == b"next called" else: mocker.patch( "monday_com_orca_backend.api.auth.main.verify_token", new=mocker.AsyncMock(), ) response = await middleware.dispatch(req, call_next_wrapper) assert called assert response.body == b"next called" @pytest.mark.asyncio async def test_valid_token_calls_next( self, mocker, dummy_app, dummy_call_next, make_request ): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app) req = make_request() verify_token_mock = mocker.patch( "monday_com_orca_backend.api.auth.main.verify_token", new=mocker.AsyncMock(), ) response = await middleware.dispatch(req, dummy_call_next) verify_token_mock.assert_awaited_once() assert response.body == b"next called" @pytest.mark.asyncio async def test_invalid_token_raises( self, mocker, dummy_app, dummy_call_next, make_request ): from monday_com_orca_backend.api.auth.main import ( JWTMiddleware, UnauthorizedException, ) middleware = JWTMiddleware(dummy_app) req = make_request() mocker.patch( "monday_com_orca_backend.api.auth.main.verify_token", new=mocker.AsyncMock(side_effect=UnauthorizedException("bad token")), ) with pytest.raises(UnauthorizedException): await middleware.dispatch(req, dummy_call_next) @pytest.mark.asyncio async def test_custom_error_handler( self, mocker, dummy_app, dummy_call_next, make_request ): from monday_com_orca_backend.api.auth.main import ( JWTMiddleware, UnauthorizedException, ) async def custom_handler(request, exc): return Response("custom error", status_code=401) middleware = JWTMiddleware(dummy_app, custom_error_handler=custom_handler) req = make_request() mocker.patch( "monday_com_orca_backend.api.auth.main.verify_token", new=mocker.AsyncMock(side_effect=UnauthorizedException("bad token")), ) response = await middleware.dispatch(req, dummy_call_next) assert response.status_code == 401 assert response.body == b"custom error" @pytest.mark.asyncio @pytest.mark.parametrize( "initial_paths,new_paths,expected", [ ([], ["/foo", "/bar"], {"/foo", "/bar"}), (["/a"], ["/b", "/c"], {"/b", "/c"}), (None, ["/x"], {"/x"}), ], ) async def test_excluded_paths_setter( self, dummy_app, initial_paths, new_paths, expected ): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app, excluded_paths=initial_paths) middleware.excluded_paths = new_paths assert middleware.excluded_paths == set(new_paths) @pytest.mark.asyncio @pytest.mark.parametrize( "initial_prefixes,new_prefixes,expected", [ ([], ["/foo", "/bar"], {"/foo", "/bar"}), (["/a"], ["/b", "/c"], {"/b", "/c"}), ( None, ["/x"], { "/x", }, ), ], ) async def test_excluded_prefixes_setter( self, dummy_app, initial_prefixes, new_prefixes, expected ): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app, excluded_prefixes=initial_prefixes) middleware.excluded_prefixes = new_prefixes assert middleware.excluded_prefixes == set(new_prefixes) @pytest.mark.asyncio @pytest.mark.parametrize( "initial,updated", [(False, True), (True, False)], ) async def test_ignore_trailing_slash_setter(self, dummy_app, initial, updated): from monday_com_orca_backend.api.auth.main import JWTMiddleware middleware = JWTMiddleware(dummy_app, ignore_trailing_slash=initial) assert middleware.ignore_trailing_slash == initial middleware.ignore_trailing_slash = updated assert middleware.ignore_trailing_slash == updated class TestVerifyToken: """Test cases for the verify_token function.""" @pytest.fixture(autouse=True) def clear_cache(self): VALID_TOKEN_CACHE.clear() yield VALID_TOKEN_CACHE.clear() class TestVerifyTokenMock: """Test cases for the verify_token function.""" # Define the set of HTTP methods to test, excluding OPTIONS, # which is handled separately _test_methods: set[HttpMethod] = { HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH, } @pytest.fixture(autouse=True) def mocker_validate_jwt_token(self, mocker): """Setup mock for validate_jwt_token.""" return mocker.patch( "monday_com_orca_backend.api.auth.main.utils.validate_jwt_token", return_value={"user_id": 123}, ) @pytest.fixture def mock_request(self, mocker): req = mocker.MagicMock(spec=Request) req.state = mocker.MagicMock() req.headers = {} req.method = "GET" return req @pytest.mark.asyncio @pytest.mark.parametrize( "method,expected", [ (HttpMethod.OPTIONS, None), *((method, pytest.raises(HTTPException)) for method in _test_methods), ], ) async def test_verify_token_options_and_missing_auth_header( self, mock_request, method, expected ): mock_request.method = method mock_request.headers = {} if expected is None: assert await verify_token(mock_request) is None else: with expected as exc: await verify_token(mock_request) assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED @pytest.mark.asyncio @pytest.mark.parametrize( "header,token_return", [ ({HttpHeader.AUTHORIZATION: "Bearer invalidtoken"}, None), ({HttpHeader.AUTHORIZATION: "InvalidFormat"}, None), ({HttpHeader.AUTHORIZATION: "Bearer "}, None), ({HttpHeader.AUTHORIZATION: ""}, None), ({HttpHeader.AUTHORIZATION: 1}, None), # Invalid type ({HttpHeader.AUTHORIZATION: None}, None), # Invalid type ({HttpHeader.AUTHORIZATION: "bearer validtoken"}, None), ({HttpHeader.AUTHORIZATION: "Bearer validtoken"}, {"user_id": 123}), ], ) async def test_verify_token_various_cases( self, mocker_validate_jwt_token, mock_request, header, token_return ): mock_request.headers = header mocker_validate_jwt_token.return_value = token_return if token_return is None: with pytest.raises(HTTPException) as exc: await verify_token(mock_request) assert exc.value.status_code == status.HTTP_401_UNAUTHORIZED else: await verify_token(mock_request) assert mock_request.state.user_payload["user_id"] == 123 @pytest.mark.asyncio async def test_verify_token_uses_cache( self, mocker_validate_jwt_token, mock_request ): payload = {"user_id": 456} mock_request.headers = {HttpHeader.AUTHORIZATION: "Bearer cachedtoken"} mocker_validate_jwt_token.return_value = payload await verify_token(mock_request) mocker_validate_jwt_token.assert_called_once() mocker_validate_jwt_token.reset_mock() await verify_token(mock_request) mocker_validate_jwt_token.assert_not_called() class TestVerifyRealSignedToken: """Test cases for verifying a real signed JWT token.""" _signed_token_expected_client_id: str = "384322de7e3635634c06ab7510fb48b2" TEST_SIGNING_SECRET = "testsecret" @pytest.fixture(autouse=True) def patcher_signing_secret(self, mocker): return mocker.patch( "monday_com_orca_backend.env_vars.MONDAY_APP_CLIENT_SECRET", self.TEST_SIGNING_SECRET, ) @pytest.fixture def fresh_signed_token(self): payload = { "dat": { "client_id": self._signed_token_expected_client_id, "user_id": 72785629, "account_id": 5625451, "slug": "theorchard", "app_id": 10354336, "app_version_id": 10841783, "install_id": -2, "is_admin": False, "is_view_only": False, "is_guest": False, "user_kind": None, }, "exp": int((datetime.now(UTC) + timedelta(hours=1)).timestamp()), } return jwt.encode(payload, self.TEST_SIGNING_SECRET, algorithm="HS256") @pytest.fixture def mock_request(self, mocker, fresh_signed_token): req = mocker.MagicMock(spec=Request) req.state = mocker.MagicMock() req.headers = {HttpHeader.AUTHORIZATION: f"Bearer {fresh_signed_token}"} return req async def get_user_payload(self, request): """Helper to get user payload from the request state.""" await verify_token(request) return request.state.user_payload @pytest.mark.asyncio async def test_verify_real_signed_token_payload(self, mock_request): user_payload = await self.get_user_payload(mock_request) got_client_id = user_payload[PayloadKey.DAT][PayloadKey.CLIENT_ID] assert got_client_id == self._signed_token_expected_client_id, ( f"Expected client_id {self._signed_token_expected_client_id}, " f"but got {got_client_id}" ) @pytest.mark.asyncio async def test_verify_real_signed_token_payload_is_immutable( self, mock_request ): user_payload = await self.get_user_payload(mock_request) with pytest.raises(TypeError): user_payload[PayloadKey.DAT][PayloadKey.CLIENT_ID] = "value"