Skip to content

Commit

Permalink
add tests for reverse proxies
Browse files Browse the repository at this point in the history
  • Loading branch information
dtkav committed Jan 24, 2019
1 parent 2fe0b0e commit 75438f2
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 18 deletions.
2 changes: 1 addition & 1 deletion connexion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def _required_lib(exec_info, *args, **kwargs):
App = FlaskApp
Api = FlaskApi

if sys.version_info[0] >= 3: # pragma: no cover
if sys.version_info >= (3, 5, 3): # pragma: no cover
try:
from .apis.aiohttp_api import AioHttpApi
from .apps.aiohttp_app import AioHttpApp
Expand Down
1 change: 1 addition & 0 deletions examples/openapi3/reverseproxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import connexion


# adapted from http://flask.pocoo.org/snippets/35/
class ReverseProxied(object):
'''Wrap the application in this middleware and configure the
Expand Down
6 changes: 4 additions & 2 deletions examples/openapi3/reverseproxy_aiohttp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
'''

import json

import connexion
from yarl import URL

from aiohttp import web
from aiohttp_remotes.x_forwarded import XForwardedBase
from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders
from aiohttp_remotes.x_forwarded import XForwardedBase
from yarl import URL

X_FORWARDED_PATH = "X-Forwarded-Path"

Expand Down
1 change: 1 addition & 0 deletions requirements-aiohttp.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
aiohttp>=2.2.5,<3.5.2
aiohttp-remotes>=0.1.2
aiohttp-swagger>=1.0.5
ujson>=1.35
aiohttp_jinja2==0.15.0
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def read_version(package):
swagger_ui_require
]

if sys.version_info[0] >= 3:
if sys.version_info >= (3, 5, 3):
tests_require.extend(aiohttp_require)
tests_require.append(ujson_require)
tests_require.append('pytest-aiohttp')
Expand All @@ -65,7 +65,7 @@ def initialize_options(self):
self.cov = None
self.pytest_args = ['--cov', 'connexion', '--cov-report', 'term-missing', '-v']

if sys.version_info[0] < 3:
if sys.version_info < (3, 5, 3):
self.pytest_args.append('--cov-config=py2-coveragerc')
self.pytest_args.append('--ignore=tests/aiohttp')
else:
Expand Down
134 changes: 134 additions & 0 deletions tests/aiohttp/test_aiohttp_reverse_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import asyncio
import sys

import pytest
from aiohttp import web
from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders
from aiohttp_remotes.x_forwarded import XForwardedBase
from connexion import AioHttpApp
from yarl import URL

X_FORWARDED_PATH = "X-Forwarded-Path"


class XPathForwarded(XForwardedBase):

def __init__(self, num=1):
self._num = num

def get_forwarded_path(self, headers):
forwarded_host = headers.getall(X_FORWARDED_PATH, [])
if len(forwarded_host) > 1:
raise TooManyHeaders(X_FORWARDED_PATH)
return forwarded_host[0] if forwarded_host else None

@web.middleware
async def middleware(self, request, handler):
try:
overrides = {}
headers = request.headers

forwarded_for = self.get_forwarded_for(headers)
if forwarded_for:
overrides['remote'] = str(forwarded_for[-self._num])

proto = self.get_forwarded_proto(headers)
if proto:
overrides['scheme'] = proto[-self._num]

host = self.get_forwarded_host(headers)
if host is not None:
overrides['host'] = host

prefix = self.get_forwarded_path(headers)
if prefix is not None:
prefix = '/' + prefix.strip('/') + '/'
request_path = URL(request.path.lstrip('/'))
overrides['rel_url'] = URL(prefix).join(request_path)

request = request.clone(**overrides)

return await handler(request)
except RemoteError as exc:
exc.log(request)
await self.raise_error(request)


@asyncio.coroutine
def test_swagger_json_behind_proxy(simple_api_spec_dir, aiohttp_client):
""" Verify the swagger.json file is returned with base_path updated
according to X-Forwarded-Path header. """
app = AioHttpApp(__name__, port=5001,
specification_dir=simple_api_spec_dir,
debug=True)
api = app.add_api('swagger.yaml')

aio = app.app
reverse_proxied = XPathForwarded()
aio.middlewares.append(reverse_proxied.middleware)

app_client = yield from aiohttp_client(app.app)
headers = {'X-Forwarded-Path': '/behind/proxy'}

swagger_ui = yield from app_client.get('/v1.0/ui/', headers=headers)
assert swagger_ui.status == 200
assert b'url = "/behind/proxy/v1.0/swagger.json"' in (
yield from swagger_ui.read()
)

swagger_json = yield from app_client.get('/v1.0/swagger.json',
headers=headers)
assert swagger_json.status == 200
assert swagger_json.headers.get('Content-Type') == 'application/json'
json_ = yield from swagger_json.json()

assert api.specification.raw['basePath'] == '/v1.0', \
"Original specifications should not have been changed"

assert json_.get('basePath') == '/behind/proxy/v1.0', \
"basePath should contains original URI"

json_['basePath'] = api.specification.raw['basePath']
assert api.specification.raw == json_, \
"Only basePath should have been updated"


@asyncio.coroutine
def test_openapi_json_behind_proxy(simple_api_spec_dir, aiohttp_client):
""" Verify the swagger.json file is returned with base_path updated
according to X-Forwarded-Path header. """
app = AioHttpApp(__name__, port=5001,
specification_dir=simple_api_spec_dir,
debug=True)

api = app.add_api('openapi.yaml')

aio = app.app
reverse_proxied = XPathForwarded()
aio.middlewares.append(reverse_proxied.middleware)

app_client = yield from aiohttp_client(app.app)
headers = {'X-Forwarded-Path': '/behind/proxy'}

swagger_ui = yield from app_client.get('/v1.0/ui/', headers=headers)
assert swagger_ui.status == 200
assert b'url: "/behind/proxy/v1.0/openapi.json"' in (
yield from swagger_ui.read()
)

swagger_json = yield from app_client.get('/v1.0/openapi.json',
headers=headers)
assert swagger_json.status == 200
assert swagger_json.headers.get('Content-Type') == 'application/json'
json_ = yield from swagger_json.json()

assert json_.get('servers', [{}])[0].get('url') == '/behind/proxy/v1.0', \
"basePath should contains original URI"

url = api.specification.raw.get('servers', [{}])[0].get('url')
assert url != '/behind/proxy/v1.0', \
"Original specifications should not have been changed"

json_['servers'] = api.specification.raw.get('servers')
assert api.specification.raw == json_, \
"Only there servers block should have been updated"
31 changes: 31 additions & 0 deletions tests/api/test_responses.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import yaml
from struct import unpack

from werkzeug.test import Client, EnvironBuilder
Expand Down Expand Up @@ -43,6 +44,36 @@ def test_app(simple_app):
assert greeting_response['greeting'] == 'Hello jsantos'


def test_openapi_yaml_behind_proxy(reverse_proxied_app):
""" Verify the swagger.json file is returned with base_path updated
according to X-Original-URI header.
"""
app_client = reverse_proxied_app.app.test_client()

headers = {'X-Forwarded-Path': '/behind/proxy'}

swagger_ui = app_client.get('/v1.0/ui/', headers=headers)
assert swagger_ui.status_code == 200

openapi_yaml = app_client.get(
'/v1.0/' + reverse_proxied_app._spec_file,
headers=headers
)
assert openapi_yaml.status_code == 200
assert openapi_yaml.headers.get('Content-Type') == 'text/yaml'
spec = yaml.load(openapi_yaml.data.decode('utf-8'))

if reverse_proxied_app._spec_file == 'swagger.yaml':
assert b'url = "/behind/proxy/v1.0/swagger.json"' in swagger_ui.data
assert spec.get('basePath') == '/behind/proxy/v1.0', \
"basePath should contains original URI"
else:
assert b'url: "/behind/proxy/v1.0/openapi.json"' in swagger_ui.data
url = spec.get('servers', [{}])[0].get('url')
assert url == '/behind/proxy/v1.0', \
"basePath should contains original URI"


def test_produce_decorator(simple_app):
app_client = simple_app.app.test_client()

Expand Down
38 changes: 38 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,44 @@ def simple_app(request):
return build_app_from_fixture('simple', request.param, validate_responses=True)


@pytest.fixture(scope="session", params=SPECS)
def reverse_proxied_app(request):

# adapted from http://flask.pocoo.org/snippets/35/
class ReverseProxied(object):

def __init__(self, app, script_name=None, scheme=None, server=None):
self.app = app
self.script_name = script_name
self.scheme = scheme
self.server = server

def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_FORWARDED_PATH', '') or self.script_name
if script_name:
environ['SCRIPT_NAME'] = "/" + script_name.lstrip("/")
path_info = environ['PATH_INFO']
if path_info.startswith(script_name):
environ['PATH_INFO_OLD'] = path_info
environ['PATH_INFO'] = path_info[len(script_name):]
scheme = environ.get('HTTP_X_SCHEME', '') or self.scheme
if scheme:
environ['wsgi.url_scheme'] = scheme
server = environ.get('HTTP_X_FORWARDED_SERVER', '') or self.server
if server:
environ['HTTP_HOST'] = server
return self.app(environ, start_response)

app = build_app_from_fixture('simple', request.param, validate_responses=True)
flask_app = app.app
proxied = ReverseProxied(
flask_app.wsgi_app,
script_name='/reverse_proxied/'
)
flask_app.wsgi_app = proxied
return app


@pytest.fixture(scope="session", params=SPECS)
def snake_case_app(request):
return build_app_from_fixture('snake_case', request.param,
Expand Down
20 changes: 8 additions & 12 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,14 @@ def test_run_with_wsgi_containers(mock_app_run, spec_file):

def test_run_with_aiohttp_not_installed(mock_app_run, spec_file):
import sys
aiohttp_bkp = sys.modules.pop('aiohttp', None)

runner = CliRunner()

# missing aiohttp
result = runner.invoke(main,
['run', spec_file, '-f', 'aiohttp'],
catch_exceptions=False)
sys.modules['aiohttp'] = aiohttp_bkp

assert 'aiohttp library is not installed' in result.output
assert result.exit_code == 1
import mock
with mock.patch.dict(sys.modules, {'aiohttp': None}):
runner = CliRunner()
result = runner.invoke(main,
['run', spec_file, '-f', 'aiohttp'],
catch_exceptions=False)
assert 'aiohttp library is not installed' in result.output
assert result.exit_code == 1


def test_run_with_wsgi_server_and_server_opts(mock_app_run, spec_file):
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ commands=
pypi: pip install -r {toxworkdir}/requirements-pypi.txt
dev: requirements-builder --level=dev --req=requirements-devel.txt -o {toxworkdir}/requirements-dev.txt setup.py
dev: pip install -r {toxworkdir}/requirements-dev.txt
py3{4,5,6,7}: pip install -r requirements-aiohttp.txt
py3{5,6,7}: pip install -r requirements-aiohttp.txt
python setup.py test

[testenv:flake8]
Expand Down

0 comments on commit 75438f2

Please sign in to comment.