From ea0fa9246c4d5a2a02088128fee500c90f444a32 Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Wed, 6 Sep 2023 19:42:37 +0530 Subject: [PATCH] feat: test webfinger response for compulsory parameters and the endpoint for CORS --- .env_sample | 3 + .gitignore | 162 ++++++++++++++++++++++++++++++++++++++++++++++ run.py | 181 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 .env_sample create mode 100644 .gitignore create mode 100755 run.py diff --git a/.env_sample b/.env_sample new file mode 100644 index 0000000..b641093 --- /dev/null +++ b/.env_sample @@ -0,0 +1,3 @@ +export FTEST_AUTH="foobar" +export FTEST_TARGET_HOST="http://localhost:3000" +export FTEST_USER="john@example.org" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..453aa62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +.env +webfinger.log diff --git a/run.py b/run.py new file mode 100755 index 0000000..f08ac7d --- /dev/null +++ b/run.py @@ -0,0 +1,181 @@ +#!/bin/env /usr/bin/python +import logging +import os +import requests +from urllib.parse import urlparse, urlunparse + + +def configure_logger(): + logger = logging.getLogger("webfinger") + logger.setLevel(logging.DEBUG) + fh = logging.FileHandler("webfinger.log") + fh.setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + fh.setFormatter(formatter) + ch.setFormatter(formatter) + logger.addHandler(fh) + logger.addHandler(ch) + return logger + + +def get_env(name) -> str: + env = FTEST_AUTH = os.environ.get(name) + logger.info(f"Environment: {name}: {env}") + return env + + +logger = configure_logger() + +FTEST_AUTH = get_env("FTEST_AUTH") +TARGET_HOST = get_env("FTEST_TARGET_HOST") +TEST_USER = get_env("FTEST_USER") # actor ex: john@example.org +TEST_HOST = urlparse(TARGET_HOST).netloc + + +def query_webfinger(): + parsed_target_host = urlparse(TARGET_HOST) + webfinger = urlunparse( + ( + parsed_target_host.scheme, + parsed_target_host.netloc, + "/.well-known/webfinger", + "", + f"resource=acct:{TEST_USER}", + "", + ) + ) + logger.info(f"Query WebFinger: {webfinger}") + res = requests.get(webfinger, headers={"Origin": "http://example.org"}) + logger.debug( + f"WebFinger response:\n\nSTATUS: {res.status_code}\n\nHEADERS:\n {res.headers}\n\nRESPONSE PAYLOAD:\n{res.json()}" + ) + assert res.status_code == 200 + logger.info("[SUCCESS] WebFinger query response is HTTP 200") + return res + + +def test_main_params(resp): + assert "subject" in resp, "Parameter 'subject' is not present in WebFinger response" + assert "links" in resp, "Parameter 'links' is not present in WebFinger response" + logger.info( + "[SUCCESS] WebFinger response has 'subject', 'aliases' and 'links' parameters'" + ) + + +def test_links(resp): + self_link = None + profile_link = None + + for link in resp["links"]: + if link["rel"] == "self": + self_link = link + elif link["rel"] == "http://webfinger.net/rel/profile-page": + profile_link = link + logger.debug( + "'rel==http://webfinger.net/rel/profile-page' is present in 'links' WebFinger response parameter" + ) + + assert ( + self_link is not None + ), "'rel==self' is not present in 'links' WebFinger response parameter" + assert ( + self_link["rel"] == "self" + ), f'[rel==self] expected rel:self, got rel: {self_link["rel"]}' + assert ( + self_link["type"] == f"application/activity+json" + ), f"[rel==self] expected application/activity+json; got {self_link['type']}" + assert "href" in self_link, "[rel==self] href not present in link item" + logger.info("[SUCESS] rel==self passed schema validation") + + if profile_link: + assert ( + profile_link["rel"] == "http://webfinger.net/rel/profile-page" + ), f"expected http://webfinger.net/rel/profile-page got {profile_link['rel']}" + assert ( + profile_link["type"] == "text/html" + ), f"expected text/html got {profile_link['type']}" + assert ( + "href" in profile_link + ), "[rel==profile link] href not present in link item" + logger.info("[SUCESS] rel==profile-page passed schema validation") + logger.info("[SUCESS] 'links' object passed validation") + + +def test_subject(resp): + subject = f"acct:{TEST_USER}" + assert ( + resp["subject"] == subject + ), f"Subject parameter doesn't match. Expected {subject} got {resp['subject']}" + + +def test_access_control_allow_origin(resp): + # request.headers is case insensitive + assert resp.headers[ + "access-control-allow-origin" + ] == "*", "Access-Control-Allow-Origin header should be '*' to allow any domain to access the resource with CORS. Please see https://www.rfc-editor.org/rfc/rfc7033.html#section-5" + logger.info("[SUCESS] WebFinger endpoint is configured correctly for CORS") + + +if __name__ == "__main__": + max_score = 5 + score = 0 + resp = query_webfinger() + json = resp.json() + score += 1 + + success = [] + failures = { } + + try: + test_main_params(json) + score += 1 + success.append("test_main_params") + except Exception as e: + logger.error(e) + failures["test_main_params"] = e + try: + test_links(json) + score += 1 + success.append("test_links") + except Exception as e: + logger.error(e) + failures["test_links"] = e + + try: + test_subject(json) + score += 1 + success.append("test_subject") + except Exception as e: + logger.error(e) + failures["test_subject"] = e + + try: + test_access_control_allow_origin(resp) + score += 1 + success.append("test_access_control_allow_origin") + except Exception as e: + logger.error(e) + failures["test_access_control_allow_origin"] = e + + + print("\n\n===============") + if score == max_score: + print("All tests passed") + elif score > 0: + print(f"Partial success. {score} out of {max_score} tests passed") + + print("Summary:\n") + + if success: + print(f"Successful tests:\n") + for s in success: + print(f"[OK] {s}") + + if failures: + print(f"\n\nFailed tests:\n") + for _, (test, error) in enumerate(failures.items()): + print(f"[FAIL] {test} failed with error:\n{error}\n-----\n")