From 9d893cb55ecdad2d2c4aa5ff9262b16e4f4caec2 Mon Sep 17 00:00:00 2001 From: Tomasz Kramkowski Date: Mon, 27 Mar 2023 18:59:54 +0100 Subject: functional tests --- poetry.lock | 86 ++++++++++++++++- pyproject.toml | 7 ++ tests/test_application.py | 240 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 tests/test_application.py diff --git a/poetry.lock b/poetry.lock index 0a66966..a75bab0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -19,6 +19,25 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope.interface"] tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] +[[package]] +name = "beautifulsoup4" +version = "4.12.0" +description = "Screen-scraping library" +category = "dev" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.0-py3-none-any.whl", hash = "sha256:2130a5ad7f513200fae61a17abb5e338ca980fa28c439c0571014bc0217e9591"}, + {file = "beautifulsoup4-4.12.0.tar.gz", hash = "sha256:c5fceeaec29d09c84970e47c65f2f0efe57872f7cff494c9691a26ec0ff13234"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "23.1.0" @@ -284,6 +303,18 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "soupsieve" +version = "2.4" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "soupsieve-2.4-py3-none-any.whl", hash = "sha256:49e5368c2cda80ee7e84da9dbe3e110b70a4575f196efb74e51b94549d921955"}, + {file = "soupsieve-2.4.tar.gz", hash = "sha256:e28dba9ca6c7c00173e34e4ba57448f0688bb681b7c5e8bf4971daafc093d69a"}, +] + [[package]] name = "tomli" version = "2.0.1" @@ -319,7 +350,60 @@ files = [ {file = "vermin-1.5.1-py2.py3-none-any.whl", hash = "sha256:420995de564ac0c31e2157220259d7ac82556e8fa69c112d8005b78c14b0caf5"}, ] +[[package]] +name = "waitress" +version = "2.1.2" +description = "Waitress WSGI server" +category = "dev" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "waitress-2.1.2-py3-none-any.whl", hash = "sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a"}, + {file = "waitress-2.1.2.tar.gz", hash = "sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba"}, +] + +[package.extras] +docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] +testing = ["coverage (>=5.0)", "pytest", "pytest-cover"] + +[[package]] +name = "webob" +version = "1.8.7" +description = "WSGI request and response object" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*" +files = [ + {file = "WebOb-1.8.7-py2.py3-none-any.whl", hash = "sha256:73aae30359291c14fa3b956f8b5ca31960e420c28c1bec002547fb04928cf89b"}, + {file = "WebOb-1.8.7.tar.gz", hash = "sha256:b64ef5141be559cfade448f044fa45c2260351edcb6a8ef6b7e00c7dcef0c323"}, +] + +[package.extras] +docs = ["Sphinx (>=1.7.5)", "pylons-sphinx-themes"] +testing = ["coverage", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "webtest" +version = "3.0.0" +description = "Helper to test WSGI applications" +category = "dev" +optional = false +python-versions = ">=3.6, <4" +files = [ + {file = "WebTest-3.0.0-py3-none-any.whl", hash = "sha256:2a001a9efa40d2a7e5d9cd8d1527c75f41814eb6afce2c3d207402547b1e5ead"}, + {file = "WebTest-3.0.0.tar.gz", hash = "sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +waitress = ">=0.8.5" +WebOb = ">=1.2" + +[package.extras] +docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.8)"] +tests = ["PasteDeploy", "WSGIProxy2", "coverage", "pyquery", "pytest", "pytest-cov"] + [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "77af368711793c4ad45130892553cfd105662c3947b72c66145b5121b480928e" +content-hash = "1c78b39f184bd07745be4d68bec51c8f3b2fa80d32a0ac6cc02cb78ef09df46c" diff --git a/pyproject.toml b/pyproject.toml index dced527..72bd94b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,13 @@ black = "^23.1.0" vermin = "^1.5.1" pyright = "^1.1.0" pytest = "^7" +webtest = "^3.0.0" + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning" +] + [build-system] requires = ["poetry-core"] diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..a7d6861 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,240 @@ +from base64 import b64decode, b64encode + +import pytest +from webtest import TestApp + +import paste.db +from paste import __main__, application + +DB = "file::memory:?cache=shared" + + +@pytest.fixture +def db(): + with paste.db.connect(DB) as d: + yield d + + +@pytest.fixture +def app(db): + _ = db + app = TestApp(application, extra_environ={"HTTP_HOST": "localhost", "PASTE_DB": DB}) + yield app + + +@pytest.fixture +def token(db): + return b64encode(__main__.generate_token(db)).decode() + + +@pytest.mark.parametrize("method", ["put", "post", "delete"]) +def test_without_apikey(app, method): + res = getattr(app, method)( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain"}, + expect_errors=True, + ) + assert res.status == "401 Unauthorized" + assert res.headers["WWW-Authenticate"] == "APIKey" + + +@pytest.mark.parametrize("method", ["put", "post", "delete"]) +def test_malformed_authorization_header(app, method): + res = getattr(app, method)( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": "malformed"}, + expect_errors=True, + ) + assert res.status == "401 Unauthorized" + assert res.headers["WWW-Authenticate"] == "APIKey" + + +@pytest.mark.parametrize("method", ["put", "post", "delete"]) +def test_malformed_apikey(app, method): + res = getattr(app, method)( + "/test_key" "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": "APIKey malformed"}, + expect_errors=True, + ) + assert res.status == "401 Unauthorized" + assert res.headers["WWW-Authenticate"] == "APIKey" + + +@pytest.mark.parametrize("method", ["put", "post", "delete"]) +def test_invalid_apikey(app, method, token): + invalid = b64encode(bytes(b ^ 13 for b in b64decode(token))).decode() + res = getattr(app, method)( + "/test_key" "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {invalid}"}, + expect_errors=True, + ) + assert res.status == "401 Unauthorized" + assert res.headers["WWW-Authenticate"] == "APIKey" + + +def test_put(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + + +def test_put_twice(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "204 No Content" + assert res.headers["Location"] == res.request.url + + +@pytest.mark.parametrize("method", ["get", "head", "delete"]) +def test_method_nonexistent_fails(app, method, token): + headers = {} + if method == "delete": + headers = {"Authorization": f"APIKey {token}"} + res = getattr(app, method)("/test_key", headers=headers, expect_errors=True) + assert res.status == "404 Not Found" + + +def test_put_then_get_then_head(app, token): + BODY = "Hello, World!" + res = app.put( + "/test_key", + BODY, + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.get("/test_key") + assert res.status == "200 OK" + assert res.headers["ETag"] == etag + assert res.text == BODY + res = app.head("/test_key") + assert res.status == "200 OK" + assert res.headers["ETag"] == etag + assert res.text == "" + + +def test_if_none_match(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.get("/test_key", headers={"If-None-Match": etag}) + assert res.status == "304 Not Modified" + assert res.headers["ETag"] == etag + + +def test_if_none_match_other_etag(app, token): + BODY = "Hello, World!" + res = app.put( + "/test_key", + BODY, + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.get("/test_key", headers={"If-None-Match": '"not a real etag"'}) + assert res.status == "200 OK" + assert res.headers["ETag"] == etag + assert res.text == BODY + + +def test_if_none_match_malformed_etag(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.get( + "/test_key", headers={"If-None-Match": "malformed"}, expect_errors=True + ) + assert res.status == "400 Bad Request" + + +@pytest.mark.parametrize( + "etags", + [ + [None, "a"], + ["a", None], + [None, "a", "b"], + ["a", None, "b"], + ["a", "b", None], + ], +) +def test_if_none_match_list(app, etags, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + etags_str = ", ".join(f'"{e}"' if e else etag for e in etags) + res = app.get("/test_key", headers={"If-None-Match": etags_str}) + assert res.status == "304 Not Modified" + assert res.headers["ETag"] == etag + + +def test_put_update(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.put( + "/test_key", + "Hello, Updated World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "204 No Content" + assert res.headers["ETag"] != etag + res = app.get("/test_key") + assert res.status == "200 OK" + assert res.text == "Hello, Updated World!" + + +def test_delete(app, token): + res = app.put( + "/test_key", + "Hello, World!", + headers={"Content-Type": "text/plain", "Authorization": f"APIKey {token}"}, + ) + assert res.status == "201 Created" + assert res.headers["Location"] == res.request.url + etag = res.headers["ETag"] + res = app.delete("/test_key", expect_errors=True) + assert res.status == "401 Unauthorized" + res = app.delete("/test_key", headers={"Authorization": f"APIKey {token}"}) + assert res.status == "204 No Content" + res = app.delete( + "/test_key", headers={"Authorization": f"APIKey {token}"}, expect_errors=True + ) + assert res.status == "404 Not Found" -- cgit v1.2.3-54-g00ecf