diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a95ab57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.github +.pytest_cache +__pycache__ +*.pyc +shadow +tests diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..9a39d69 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,37 @@ +name: Publish container + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4.2.0 + - uses: docker/login-action@v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + - uses: docker/build-push-action@v7.3.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6b1eb5b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6.3.0 + with: + python-version: "3.12" + cache: pip + - run: pip install --requirement requirements-dev.txt + - run: python -m compileall -q app + - run: pytest --cov=app --cov-report=term-missing + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4.2.0 + - uses: docker/build-push-action@v7.3.0 + with: + context: . + push: false diff --git a/.gitignore b/.gitignore index d1d147f..7292ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ build/ develop-eggs/ dist/ @@ -66,4 +67,6 @@ target/ # Private config and keys shadow -*.pem # excessive but for avoid attention +*.pem +.pytest_cache/ +.ruff_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa92cf7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/app + +RUN groupadd --system app && useradd --system --gid app --home /app app +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir --requirement requirements.txt + +COPY --chown=app:app app ./app +USER app + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"] + +CMD ["gunicorn", "--bind=0.0.0.0:8000", "--workers=2", "--threads=2", "--access-logfile=-", "--error-logfile=-", "app_main:create_app()"] diff --git a/README.md b/README.md index ec51f84..4ca118c 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,60 @@ -## Abstract +# chtoes.li feedback API -The simple flask application implements an API for the chtoes.li website. +Small Flask API used by the feedback form on [chtoes.li](https://chtoes.li). +It validates reader submissions and creates issues in `whatifrussian/website` +through an installed GitHub App using PyGithub. -## Setup +## Development -The private configuration and keys supposed to be into `shadow/` directory sibling to `app/` one. This directory is NOT synced with the server by `fab publish` and generally differs between local (debug) and server (production) setup. It should be created manually. +Python 3.12 is the supported runtime. -`shadow/config.json` -- overwrites `app/config.json` and has the same format. This file necessary to set credentials for interacting with GitHub Integrations API and keeping its out of version control. Any other settings (like target repository for issues) can be set there to leave repository clean during developing / debugging. +```console +python -m venv .venv +. .venv/bin/activate +pip install -r requirements-dev.txt +pytest +``` -**Warning:** The shadow config non-deeply merged into the public one. While it’s possible to omit a first-level key in the shadow config, the overwritten value should consist all corresponding keys and not only modified ones. +Private GitHub App configuration belongs in `shadow/config.json`. The directory +is ignored by Git. It overrides top-level values from `app/config.json` and must +provide the complete `github` object: + +```json +{ + "github": { + "integration_id": 123, + "installation_id": 456, + "secret_key_file": "shadow/github-app.private-key.pem" + } +} +``` -## Deploy +The private key path may be absolute or relative to the repository root. Set +`CHTOES_CONFIG` to use a configuration file at another location. -As simple as: +## Container -``` -user@host api $ fab publish -``` +Build and test the image locally: -It just sync actual content of the `app/` directory with the server and reloads uwsgi. +```console +docker build -t chtoes-api . +docker run --rm -p 127.0.0.1:8000:8000 \ + -v "$PWD/shadow:/app/shadow:ro" chtoes-api +curl http://127.0.0.1:8000/healthz +``` -## Debug +`compose.prod.yml` runs the published GHCR image on `127.0.0.1:8090`. It mounts +the server's existing private configuration read-only, uses a read-only root +filesystem, drops root, and enables automatic restart. -The website expects `/api/issue/` to be on the same domain as the website. It makes debugging on a local machine tricky, but still possible. +The nginx proxy snippet and production rollback notes live in `deploy/`. -The idea is to run flask's webserver on the same port as a webserver serving the website. Example: +## Release -``` -user@host website $ fab reserve:debug # or reserve:local -... open http://localhost:8000 in a browser ... -user@host website $ ^C # stop the website's server -... cd to api ... -user@host api $ export FLASK_APP=app/app_main.py -user@host api $ flask run -p 8000 -... use the issue form in the browser ... -``` +Pull requests and pushes to `master` run Python tests and build the image. +Tags matching `v*` publish multi-architecture images to +`ghcr.io/whatifrussian/api` with version and `latest` tags. ## License -Public domain. You free to use it as you need without any restrictions. No guarantees provided. - -Consider LICENSE file for more information. +Public domain. See `LICENSE`. diff --git a/app/app_issue.py b/app/app_issue.py index 08ff59a..e9c0cc8 100644 --- a/app/app_issue.py +++ b/app/app_issue.py @@ -1,111 +1,90 @@ +import logging import re -from traceback import format_exc -from flask import Blueprint, request -from flask import current_app as app -from github import GitHubAPI -from utils import get_base_dir, get_config, dump_to_json -from app_log import log_request +from flask import Blueprint, jsonify, request +from github import GithubException -app_issue = Blueprint('issue', __name__) -gitHub = GitHubAPI(base_dir=get_base_dir(), **get_config('github')) -escape_md_re = re.compile(r'([*_\\<>\[\]`])') +LOGGER = logging.getLogger(__name__) +EXPECTED_FIELDS = { + "comment", + "slug", + "before_far", + "before_near", + "sel", + "after_near", + "after_far", +} +ESCAPE_MARKDOWN = re.compile(r"([*_\\<>\[\]`])") -# stub for receiving events -@app_issue.route('/api/github-gate/', methods=['POST']) -def github_gate(): - log_request(request, 'api-github-gate') - return 'ok' +def validate_issue_request(req): + if req.headers.get("X-Requested-With") != "XMLHttpRequest": + return None, "X-Requested-With must be XMLHttpRequest" + if not req.is_json: + return None, "Content-Type must be application/json" -def is_issue_request_valid(request): - exp_headers = { - 'X-Requested-With': ('XMLHttpRequest',), - 'Content-Type': ( - 'application/json; charset=utf-8', - 'application/json; charset=UTF-8', - ), + payload = req.get_json(silent=True) + if not isinstance(payload, dict): + return None, "Request body must be a JSON object" + if set(payload) != EXPECTED_FIELDS: + return None, "Request body contains unexpected or missing fields" + if not all(isinstance(payload[field], str) for field in EXPECTED_FIELDS): + return None, "All request fields must be strings" + return payload, None + + +def render_issue(template, user_data): + escaped = { + key: ESCAPE_MARKDOWN.sub(r"\\\1", value) + if key != "comment" + else value + for key, value in user_data.items() } - for k, exp_v in exp_headers.items(): - value = request.headers.get(k) - if value not in exp_v: - exp_v_str = '* "' + '"\n* "'.join(exp_v) + '"' - why = ('Header "{k}" is "{value}", ' + - 'expected one of the following:\n' + - '{exp_v}').format(k=k, value=value, exp_v=exp_v_str) - return False, why - exp_keys = {'comment', 'slug', 'before_far', 'before_near', 'sel', - 'after_near', 'after_far'} - user_data = request.get_json() - if not user_data: - why = 'The request cannot be interpreted as JSON' - return False, why - keys = user_data.keys() - if keys ^ exp_keys != set(): - why = 'Keys are {keys}, expected {exp_keys}'.format( - keys=keys, exp_keys=exp_keys) - return False, why - return True, None - - -def escape_sel_context(user_data): - res = dict() - exp_keys = {'before_far', 'before_near', 'sel', 'after_near', 'after_far'} - for k, v in user_data.items(): - if k in exp_keys: - res[k] = escape_md_re.sub(r'\\\1', user_data[k]) - else: - res[k] = user_data[k] - return res - - -@app_issue.route('/api/issue/', methods=['POST']) -def api_issue(): - log_request(request, 'api-issue') - # prepare constants - headers = {'Content-Type': 'application/json; charset=utf-8'} - bad_payload = { - 'success': False, - 'num': None, - 'url': None, + body_parts = [] + line_prefix = "" + for item in template["body"]: + if isinstance(item, dict): + line_prefix = item["line_prefix"] + continue + rendered = item.format_map(escaped) + body_parts.append(line_prefix + ("\n" + line_prefix).join(rendered.split("\n"))) + + return { + "owner": template["owner"], + "repo": template["repo"], + "title": template["title"].format_map(escaped), + "body": "".join(body_parts).rstrip("\n"), + "labels": template["labels"], + "assignees": template["assignees"], } - bad_request_response = (dump_to_json(bad_payload), 400, headers) - # get and validate user's data - valid, why = is_issue_request_valid(request) - if not valid: - log_request(request, 'api-issue-errors', why) - return bad_request_response - user_data = request.get_json() - user_data = escape_sel_context(user_data) - # get template - exp_keys = {'owner', 'repo', 'title', 'body', 'labels', 'assignees'} - issue_tmpl = get_config('issue_template', exp_keys) - # specify template - as_is_keys = {'owner', 'repo', 'labels', 'assignees'} - issue = {k: issue_tmpl[k] for k in as_is_keys} - issue['title'] = issue_tmpl['title'].format_map(user_data) - issue['body'] = '' - line_prefix = '' - for line in issue_tmpl['body']: - if isinstance(line, dict): - line_prefix = line['line_prefix'] - else: - line = line.format_map(user_data) - line = ('\n' + line_prefix).join(line.split('\n')) - issue['body'] += line_prefix + line - issue['body'] = issue['body'].rstrip('\n') - # create issue - try: - num, url = gitHub.create_issue(**issue) - payload = { - 'success': True, - 'num': num, - 'url': url, - } - except: - why = 'The exception raised in api_issue:\n' + format_exc() - app.logger.warning(why) - log_request(request, 'api-issue-errors', why) - return bad_request_response - return (dump_to_json(payload), 200, headers) + + +def create_issue_blueprint(github_client, issue_template): + blueprint = Blueprint("issue", __name__) + + @blueprint.post("/api/github-gate/") + def github_gate(): + return "ok" + + @blueprint.post("/api/issue/") + def api_issue(): + payload, error = validate_issue_request(request) + if error: + LOGGER.info("Rejected issue request: %s", error) + return jsonify(success=False, num=None, url=None), 400 + + try: + number, url = github_client.create_issue( + **render_issue(issue_template, payload) + ) + except GithubException: + LOGGER.exception("GitHub rejected issue creation") + return jsonify(success=False, num=None, url=None), 502 + except Exception: + LOGGER.exception("Unexpected issue creation failure") + return jsonify(success=False, num=None, url=None), 500 + + return jsonify(success=True, num=number, url=url) + + return blueprint diff --git a/app/app_log.py b/app/app_log.py deleted file mode 100644 index 37d0236..0000000 --- a/app/app_log.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -import logging -from flask import Blueprint, request -from flask import current_app as app -from utils import safe_mkdir, dump_to_json, get_base_dir - - -app_log = Blueprint('log', __name__) -LOGGER_NAME_PREFIX = __name__ + '.' - - -def log_request(request, name, why=None): - """ Log request with particular logger (to corresponding file) """ - logger = logging.getLogger(LOGGER_NAME_PREFIX + name) - logger.info('=================') - logger.info('Method: {method}'.format(method=request.method)) - # headers from WSGI environment are not sorted - headers = dump_to_json(dict(request.headers)) - logger.info('---- Headers ----\n' + headers) - if request.is_json: - json = dump_to_json(request.get_json()) - logger.info('---- JSON ----\n' + json) - else: - data = request.get_data().decode() - logger.info('---- Data ----\n' + data) - if why: - logger.info('---- Why bad ----\n' + why) - - -# Setup loggers -# ============= - - -# explicitly name default logger, because a logger names will matched to a file -# names -@app_log.record -def name_default_logger(setup_state): - setup_state.app.config['LOGGER_NAME'] = LOGGER_NAME_PREFIX + 'default' - - -@app_log.before_app_first_request -def setup_loggers(): - logdir = os.path.join(get_base_dir(), 'log') - safe_mkdir(logdir) - - def setup_file_logger(logger): - short_name = logger.name[len(LOGGER_NAME_PREFIX):] - file = os.path.join(logdir, '{name}.log'.format(name=short_name)) - fileHandler = logging.FileHandler(file) - formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') - fileHandler.setFormatter(formatter) - logger.addHandler(fileHandler) - - setup_file_logger(app.logger) - - # set logger class for future loggers - class RequestLogger(logging.Logger): - def __init__(self, name): - super(RequestLogger, self).__init__(name) - setup_file_logger(self) - self.setLevel(logging.INFO) - logging.setLoggerClass(RequestLogger) - - -@app_log.before_app_request -def log_all_requests(): - log_request(request, 'requests') diff --git a/app/app_main.py b/app/app_main.py index a2b56fc..a9a560b 100755 --- a/app/app_main.py +++ b/app/app_main.py @@ -1,15 +1,41 @@ -#!/usr/bin/env python +import logging +import os +from flask import Flask, jsonify, request -from flask import Flask -from app_log import app_log -from app_issue import app_issue +from app_issue import create_issue_blueprint +from github_api import GitHubAPI +from utils import get_base_dir, get_config -app = Flask(__name__) -app.register_blueprint(app_log) -app.register_blueprint(app_issue) +def create_app(github_client=None, config=None): + app = Flask(__name__) + config = config or get_config() + if github_client is None: + github_client = GitHubAPI(base_dir=get_base_dir(), **config["github"]) -if __name__ == '__main__': - app.run() + app.register_blueprint( + create_issue_blueprint(github_client, config["issue_template"]) + ) + + @app.get("/healthz") + def healthz(): + return jsonify(status="ok") + + @app.after_request + def log_request(response): + app.logger.info( + "%s %s %s", request.method, request.path, response.status_code + ) + return response + + return app + + +if __name__ == "__main__": + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + create_app().run(host="0.0.0.0", port=8000) diff --git a/app/github.py b/app/github.py deleted file mode 100644 index a8ced3d..0000000 --- a/app/github.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -from datetime import datetime, timedelta, timezone -import requests -import jwt - - -class GitHubAPI: - class InternalError(Exception): - def __init__(self, desc): - super(InternalError, self).__init__(desc) - - def __init__(self, secret_key_file, base_dir=None, **kwargs): - self.API_BASE = 'https://api.github.com' - self.integration_id = kwargs['integration_id'] - if base_dir: - self.secret_key_file = os.path.join(base_dir, secret_key_file) - else: - self.secret_key_file = secret_key_file - self.installation_id = kwargs['installation_id'] - self.read_secret_key() - - def read_secret_key(self): - with open(self.secret_key_file, 'r') as f: - self.secret_key = f.read() - - def get_jwt(self): - now = datetime.now(timezone.utc) - exp = now + timedelta(minutes=1) - payload = { - 'iat': int(now.timestamp()), - 'exp': int(exp.timestamp()), - 'iss': self.integration_id, - } - jwt_bytes = jwt.encode(payload, self.secret_key, algorithm='RS256') - return jwt_bytes.decode('utf-8') - - def get_token(self): - url = self.API_BASE + '/installations/{id}/access_tokens'.format( - id=self.installation_id) - headers = self.get_headers('jwt') - response = requests.post(url, headers=headers) - return response.json()['token'] - - def get_headers(self, auth_type): - headers = {'Accept': 'application/vnd.github.machine-man-preview+json'} - if auth_type == 'jwt': - jwt = self.get_jwt() - headers.update( - {'Authorization': 'Bearer {jwt}'.format(jwt=jwt)}) - elif auth_type == 'token': - token = self.get_token() - headers.update( - {'Authorization': 'token {token}'.format(token=token)}) - else: - raise InternalError('auth_type param should be "jwt" or "token"') - return headers - - def create_issue(self, **kwargs): - headers = self.get_headers('token') - payload_fields = ['title', 'body', 'labels', 'assignees'] - payload = {k: kwargs[k] for k in payload_fields} - url = self.API_BASE + '/repos/{owner}/{repo}/issues'.format(**kwargs) - response = requests.post(url, json=payload, headers=headers) - issue_num = response.json()['number'] - issue_url = response.json()['html_url'] - - return issue_num, issue_url diff --git a/app/github_api.py b/app/github_api.py new file mode 100644 index 0000000..731db3d --- /dev/null +++ b/app/github_api.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from github import Auth, Github + + +class GitHubAPI: + """Create issues as an installed GitHub App.""" + + def __init__(self, secret_key_file, integration_id, installation_id, base_dir=None): + key_path = Path(secret_key_file) + if base_dir and not key_path.is_absolute(): + key_path = Path(base_dir) / key_path + + private_key = key_path.read_text(encoding="utf-8") + app_auth = Auth.AppAuth(integration_id, private_key) + installation_auth = app_auth.get_installation_auth(int(installation_id)) + self.github = Github(auth=installation_auth, timeout=15, retry=3) + + def create_issue(self, *, owner, repo, title, body, labels, assignees): + issue = self.github.get_repo(f"{owner}/{repo}").create_issue( + title=title, + body=body, + labels=labels, + assignees=assignees, + ) + return issue.number, issue.html_url diff --git a/app/utils.py b/app/utils.py index d90d4b1..727d622 100644 --- a/app/utils.py +++ b/app/utils.py @@ -61,10 +61,13 @@ def read_config_file(config_file): if not get_config.config_data: base_dir = get_base_dir() public_cfg = os.path.join(base_dir, 'app', 'config.json') - shadow_cfg = os.path.join(base_dir, 'shadow', 'config.json') + shadow_cfg = os.environ.get( + 'CHTOES_CONFIG', os.path.join(base_dir, 'shadow', 'config.json') + ) data = dict() data.update(read_config_file(public_cfg)) - data.update(read_config_file(shadow_cfg)) + if os.path.isfile(shadow_cfg): + data.update(read_config_file(shadow_cfg)) exp_keys_top = {'issue_template', 'github'} validate_keys(data.keys(), exp_keys_top) get_config.config_data = data diff --git a/app/uwsgi.ini b/app/uwsgi.ini deleted file mode 100644 index 2b52332..0000000 --- a/app/uwsgi.ini +++ /dev/null @@ -1,15 +0,0 @@ -[uwsgi] -plugins-dir=/usr/lib/uwsgi/plugins/ -plugin=python34 -base=/var/www/api.chtoes.li/public/app -callable=app -module=app_main -#wsgi-file=%(base)/app_main.py -pythonpath=%(base) -logto=/var/log/uwsgi/%n.log -uid=www-data -gid=www-data -workers=1 -processes=1 -threads=1 -touch-reload=%(base)/../touch-reload diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..cfd70e6 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,16 @@ +services: + api: + image: ghcr.io/whatifrussian/api:latest + container_name: chtoes-api + # Match the owner of the existing 0600 GitHub App key on libc6.org. + user: "33:33" + restart: unless-stopped + ports: + - "127.0.0.1:8090:8000" + volumes: + - /var/www/api.chtoes.li/public/shadow:/app/shadow:ro + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + security_opt: + - no-new-privileges:true diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index 453ddda..0000000 --- a/fabfile.py +++ /dev/null @@ -1,17 +0,0 @@ -from fabric.api import env, run -import fabric.contrib.project as project - - -env.user = 'www-data' -env.hosts = ['chtoes.li'] - - -def publish(): - base_dir = '/var/www/api.chtoes.li/public' - project.rsync_project( - local_dir='app/', - remote_dir='{base_dir}/app/'.format(base_dir=base_dir), - exclude=['.*.sw[a-z]', '*.pyc', '__pycache__'], - delete=True, - ) - run('touch {base_dir}/touch-reload'.format(base_dir=base_dir)) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f794fa9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +pytest-cov==7.0.0 diff --git a/requirements.txt b/requirements.txt index 455c25c..c2360b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -requests -cryptography -pyjwt-1.4.2 -flask-0.11.1 -Fabric3 +Flask==3.1.2 +gunicorn==23.0.0 +PyGithub==2.8.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a445cc1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).parents[1] / "app")) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..024a74f --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace + +import pytest +from github import GithubException + +from app_main import create_app + + +PAYLOAD = { + "comment": "Please fix this", + "slug": "example", + "before_far": "before ", + "before_near": "near ", + "sel": "wrong*text", + "after_near": " after", + "after_far": " far", +} +CONFIG = { + "issue_template": { + "owner": "whatifrussian", + "repo": "website", + "title": "Typo on page {slug}", + "body": [ + "Typo on https://chtoes.li/{slug}\n\n", + {"line_prefix": "> "}, + "{before_far}**{before_near}|{sel}|{after_near}**{after_far}", + {"line_prefix": ""}, + "\n\nReader's comment:\n\n", + "{comment}", + ], + "labels": ["typo"], + "assignees": ["Totktonada"], + }, + "github": {}, +} + + +class FakeGitHub: + def __init__(self, error=None): + self.error = error + self.calls = [] + + def create_issue(self, **issue): + self.calls.append(issue) + if self.error: + raise self.error + return 123, "https://github.com/whatifrussian/website/issues/123" + + +@pytest.fixture +def client_and_github(): + github = FakeGitHub() + app = create_app(github_client=github, config=CONFIG) + app.config["TESTING"] = True + return app.test_client(), github + + +def post_issue(client, payload=PAYLOAD, **headers): + return client.post( + "/api/issue/", + json=payload, + headers={"X-Requested-With": "XMLHttpRequest", **headers}, + ) + + +def test_health(client_and_github): + client, _ = client_and_github + assert client.get("/healthz").get_json() == {"status": "ok"} + + +def test_creates_rendered_issue(client_and_github): + client, github = client_and_github + response = post_issue(client) + + assert response.status_code == 200 + assert response.get_json()["num"] == 123 + assert github.calls[0]["title"] == "Typo on page example" + assert "wrong\\*text" in github.calls[0]["body"] + assert github.calls[0]["labels"] == ["typo"] + + +def test_rejects_missing_ajax_header(client_and_github): + client, github = client_and_github + response = client.post("/api/issue/", json=PAYLOAD) + assert response.status_code == 400 + assert github.calls == [] + + +def test_rejects_unexpected_fields(client_and_github): + client, github = client_and_github + response = post_issue(client, {**PAYLOAD, "extra": "value"}) + assert response.status_code == 400 + assert github.calls == [] + + +def test_github_error_returns_bad_gateway(): + github = FakeGitHub(GithubException(403, {"message": "forbidden"})) + app = create_app(github_client=github, config=CONFIG) + response = post_issue(app.test_client()) + assert response.status_code == 502 + assert response.get_json() == {"success": False, "num": None, "url": None} diff --git a/tests/test_github_api.py b/tests/test_github_api.py new file mode 100644 index 0000000..6c5df98 --- /dev/null +++ b/tests/test_github_api.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace + +from github_api import GitHubAPI + + +def test_create_issue_uses_pygithub_repository(): + created = SimpleNamespace(number=42, html_url="https://example.test/issues/42") + repo = SimpleNamespace(create_issue=lambda **kwargs: created) + + class FakeGithub: + def get_repo(self, name): + assert name == "owner/repo" + return repo + + api = GitHubAPI.__new__(GitHubAPI) + api.github = FakeGithub() + number, url = api.create_issue( + owner="owner", + repo="repo", + title="title", + body="body", + labels=["typo"], + assignees=["person"], + ) + assert (number, url) == (42, "https://example.test/issues/42")