From 3eff8d1cb3a677aa71712b17876bc21606cfe88e Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 2 Jul 2026 15:31:56 -0500 Subject: [PATCH] feat: opt-in Docker-based Gitea integration test suite (#66) (#97) Co-authored-by: Jason Walker <913443@dadeschools.net> Co-committed-by: Jason Walker <913443@dadeschools.net> --- docs/developer-testing-guidelines.md | 17 ++-- tests/integration/README.md | 60 ++++++++++++++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 65 +++++++++++++++ tests/integration/docker-compose.yml | 19 +++++ tests/integration/gitea-integration | 59 ++++++++++++++ tests/integration/test_gitea_live.py | 115 +++++++++++++++++++++++++++ 7 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 tests/integration/README.md create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/docker-compose.yml create mode 100755 tests/integration/gitea-integration create mode 100644 tests/integration/test_gitea_live.py diff --git a/docs/developer-testing-guidelines.md b/docs/developer-testing-guidelines.md index dc5693c..291ff5d 100644 --- a/docs/developer-testing-guidelines.md +++ b/docs/developer-testing-guidelines.md @@ -208,17 +208,18 @@ git diff --cached | grep -nEi "authorization: (basic|bearer)|password|token=[A-Z --- -## 8. Unit tests vs. future Docker integration tests +## 8. Unit tests vs. Docker integration tests -* **Unit tests (today, default):** fast, fully mocked, no network, no keychain. +* **Unit tests (default):** fast, fully mocked, no network, no keychain. This is where the vast majority of coverage lives and where new tests should go. They must stay fast and must not require credentials. -* **Docker/local-Gitea integration tests (planned, see #66):** opt-in and - skipped by default, gated behind an explicit environment variable and run - against a pinned, disposable Gitea container. They validate real API behavior - (pagination, permissions, label/PR-review endpoints, error payloads) that - mocks cannot prove. They must not require production credentials and must not - leak tokens. +* **Docker/local-Gitea integration tests (#66, `tests/integration/`):** opt-in + and skipped by default — enabled only by `GITEA_INTEGRATION=1` and run + against a pinned, disposable Gitea container + (`tests/integration/gitea-integration up|token|down`). They validate real + API behavior (pagination, permissions, label endpoints, error payloads) that + mocks cannot prove. They must not use production credentials and must not + leak tokens. See [`../tests/integration/README.md`](../tests/integration/README.md). Rule of thumb: prove **logic and request-shaping** with unit tests; reserve integration tests for **real-server compatibility**. Do not convert unit tests diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..da57a70 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,60 @@ +# Opt-in Gitea Integration Tests (#66) + +Real-Gitea integration tests for the shared API client +(`gitea_auth.api_request` / `api_get_all`). **Skipped by default** — the unit +suite (`pytest tests/ -q`) stays fast, mocked, and network-free. + +## What they prove + +Against a real, disposable Gitea instance: + +- issue listing + pagination (multi-page walk, overall `limit`) +- PR listing through the same paginated client +- targeted label add/remove (one label removed, others untouched) +- permission denial fails closed (bad token → clear `401` error, token never echoed) +- real Gitea error payloads surface as safe, redacted `RuntimeError`s + +## Environment variables + +| Variable | Meaning | +|---|---| +| `GITEA_INTEGRATION=1` | opt-in switch — the suite is skipped without it | +| `GITEA_INTEGRATION_URL` | base URL (default `http://localhost:3003`) | +| `GITEA_INTEGRATION_TOKEN` | API token for the **local test** instance | + +Never point these at a production Gitea and never use production tokens. The +token is used only in the `Authorization` header; tests assert it does not +appear in any error output. + +## Quick start (Docker) + +```bash +tests/integration/gitea-integration up +export GITEA_INTEGRATION_TOKEN="$(tests/integration/gitea-integration token)" +GITEA_INTEGRATION=1 ./venv/bin/python -m pytest tests/integration/ -q +tests/integration/gitea-integration down +``` + +- Image is **pinned** (`gitea/gitea:1.22.6` in `docker-compose.yml`); bump the + pin deliberately, never `:latest`. +- `up` waits until `/api/v1/version` answers (60s timeout). +- `token` idempotently creates a TEST-ONLY admin (`inttest-admin`) inside the + container and prints a fresh API token to stdout — nothing is written to disk. +- `down` removes the container **and its volume** (full teardown). + +An existing local Gitea works too: set `GITEA_INTEGRATION_URL` and a token for +any account allowed to create/delete its own repos. + +## Seed data and cleanup + +- Each session creates one uniquely-named repo (`inttest-<8 hex>`), seeds + issues/labels/one PR inside it, and deletes the repo on teardown + (best-effort; a leaked repo is disposable and obvious). +- Nothing outside the seed repo is touched; the suite never mutates + pre-existing repos, users, or instance settings. + +## Relationship to the unit suite + +Unit tests remain the default and must stay mocked. Add an integration test +only for behavior that genuinely depends on a real server (pagination +metadata, permission semantics, live error payloads). diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..c6fd1a0 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,65 @@ +"""Fixtures for the opt-in Docker/local Gitea integration suite (#66). + +Everything here is inert unless GITEA_INTEGRATION=1 — the test modules carry +a module-level skipif, so a default ``pytest tests/ -q`` run never touches the +network and never needs Docker. + +Required environment (see tests/integration/README.md): +- GITEA_INTEGRATION=1 opt-in switch +- GITEA_INTEGRATION_URL base URL (default http://localhost:3003) +- GITEA_INTEGRATION_TOKEN API token for the *local test* instance + +The token is a throwaway credential for the disposable container. It is never +printed, logged, or asserted on. Production credentials must never be used. +""" +import os +import sys +import uuid +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from gitea_auth import api_request # noqa: E402 + +ENABLED = os.environ.get("GITEA_INTEGRATION") == "1" + + +def _base_url(): + return os.environ.get("GITEA_INTEGRATION_URL", "http://localhost:3003").rstrip("/") + + +@pytest.fixture(scope="session") +def gitea(): + """Session facts: base URL, auth header string, authenticated login.""" + token = os.environ.get("GITEA_INTEGRATION_TOKEN") + if not token: + pytest.fail( + "GITEA_INTEGRATION=1 but GITEA_INTEGRATION_TOKEN is unset; " + "run tests/integration/gitea-integration token" + ) + base = _base_url() + auth = f"token {token}" + me = api_request("GET", f"{base}/api/v1/user", auth) + return {"base": base, "auth": auth, "login": me["login"]} + + +@pytest.fixture(scope="session") +def seed_repo(gitea): + """Create a disposable, uniquely-named repo; delete it on teardown.""" + name = f"inttest-{uuid.uuid4().hex[:8]}" + repo = api_request( + "POST", f"{gitea['base']}/api/v1/user/repos", gitea["auth"], + payload={"name": name, "auto_init": True, + "description": "gitea-tools #66 integration seed (disposable)"}, + ) + owner = repo["owner"]["login"] + yield {"owner": owner, "name": name, + "api": f"{gitea['base']}/api/v1/repos/{owner}/{name}"} + # Teardown: best-effort delete; a leaked repo is visible and disposable. + try: + api_request("DELETE", f"{gitea['base']}/api/v1/repos/{owner}/{name}", + gitea["auth"]) + except RuntimeError: + pass diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000..26e159e --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,19 @@ +# Disposable Gitea for the opt-in integration suite (#66). +# Pinned image for reproducibility — bump deliberately, never use :latest. +# Usage: tests/integration/gitea-integration up|token|down +services: + gitea: + image: gitea/gitea:1.22.6 + container_name: gitea-tools-integration + environment: + - GITEA__security__INSTALL_LOCK=true + - GITEA__server__ROOT_URL=http://localhost:3003/ + - GITEA__server__HTTP_PORT=3000 + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__log__LEVEL=Warn + ports: + - "3003:3000" + volumes: + - gitea-integration-data:/data +volumes: + gitea-integration-data: diff --git a/tests/integration/gitea-integration b/tests/integration/gitea-integration new file mode 100755 index 0000000..9026d00 --- /dev/null +++ b/tests/integration/gitea-integration @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# gitea-integration — manage the disposable Gitea container for the opt-in +# integration suite (#66). See tests/integration/README.md. +# +# tests/integration/gitea-integration up start pinned Gitea, wait ready +# tests/integration/gitea-integration token create test admin + print token +# tests/integration/gitea-integration down stop container, delete volume +# +# The admin credentials below are TEST-ONLY, for the throwaway local container +# started by this script. They are not secrets and must never be reused for a +# real instance. The generated API token is printed ONCE to stdout (for +# `export GITEA_INTEGRATION_TOKEN=$(...)`) and is never written to any file. + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +compose() { docker compose -f "$here/docker-compose.yml" "$@"; } + +base_url="${GITEA_INTEGRATION_URL:-http://localhost:3003}" +admin_user="inttest-admin" +admin_pass="inttest-local-only-pass" # TEST-ONLY, disposable container +admin_email="inttest-admin@example.invalid" + +case "${1:-}" in + up) + compose up -d + printf 'gitea-integration: waiting for %s ' "$base_url" + for _ in $(seq 1 60); do + if curl -fsS "$base_url/api/v1/version" >/dev/null 2>&1; then + printf '\ngitea-integration: ready\n' + exit 0 + fi + printf '.' + sleep 1 + done + printf '\ngitea-integration: Gitea did not become ready in 60s\n' >&2 + exit 1 + ;; + token) + # Idempotent admin creation (ignore "already exists"). + compose exec -T -u git gitea gitea admin user create \ + --username "$admin_user" --password "$admin_pass" \ + --email "$admin_email" --admin --must-change-password=false \ + >/dev/null 2>&1 || true + # Unique token name per call; Gitea prints the token as the last field. + tok_name="inttest-$(date +%s)" + out="$(compose exec -T -u git gitea gitea admin user generate-access-token \ + --username "$admin_user" --scopes all --token-name "$tok_name")" + printf '%s\n' "$out" | sed -n 's/.*successfully created[:!]* *//p' | tr -d '[:space:]' + printf '\n' + ;; + down) + compose down -v + ;; + *) + printf 'usage: tests/integration/gitea-integration up|token|down\n' >&2 + exit 2 + ;; +esac diff --git a/tests/integration/test_gitea_live.py b/tests/integration/test_gitea_live.py new file mode 100644 index 0000000..92d2d17 --- /dev/null +++ b/tests/integration/test_gitea_live.py @@ -0,0 +1,115 @@ +"""Opt-in integration tests against a real (containerized) Gitea (#66). + +Skipped by default. Enable with GITEA_INTEGRATION=1 plus a local instance and +token — see tests/integration/README.md. These prove real Gitea behavior that +the mocked unit suite can only simulate: pagination, targeted label edits, +permission fail-closed, and error payload surfacing — all through the shared +client (``gitea_auth.api_request`` / ``api_get_all``, #65/#67 shape). + +No production credentials; tokens never appear in output or assertions. +""" +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from gitea_auth import api_request, api_get_all # noqa: E402 + +pytestmark = pytest.mark.skipif( + os.environ.get("GITEA_INTEGRATION") != "1", + reason="integration tests are opt-in: set GITEA_INTEGRATION=1 (see tests/integration/README.md)", +) + + +# --- issue listing + pagination -------------------------------------------- + +N_ISSUES = 7 # > page_size below, forcing a real multi-page walk + + +@pytest.fixture(scope="module") +def issues(gitea, seed_repo): + created = [] + for i in range(N_ISSUES): + created.append(api_request( + "POST", f"{seed_repo['api']}/issues", gitea["auth"], + payload={"title": f"pagination seed {i}"})) + return created + + +def test_issue_pagination_walks_all_pages(gitea, seed_repo, issues): + got = api_get_all(f"{seed_repo['api']}/issues?state=open", gitea["auth"], + page_size=3) + titles = {i["title"] for i in got} + assert {f"pagination seed {i}" for i in range(N_ISSUES)} <= titles + assert len(got) >= N_ISSUES + + +def test_issue_pagination_honors_overall_limit(gitea, seed_repo, issues): + got = api_get_all(f"{seed_repo['api']}/issues?state=open", gitea["auth"], + page_size=3, limit=4) + assert len(got) == 4 + + +# --- PR listing -------------------------------------------------------------- + +def test_pr_listing_via_shared_client(gitea, seed_repo, issues): + # Create a branch (via the contents API) and a real PR, then list. + api_request( + "POST", f"{seed_repo['api']}/contents/inttest.txt", gitea["auth"], + payload={"content": "aW50ZWdyYXRpb24gc2VlZAo=", # "integration seed" + "message": "seed PR branch", "new_branch": "inttest-pr"}) + pr = api_request( + "POST", f"{seed_repo['api']}/pulls", gitea["auth"], + payload={"title": "integration seed PR", "head": "inttest-pr", + "base": "main"}) + got = api_get_all(f"{seed_repo['api']}/pulls?state=open", gitea["auth"], + page_size=1) + assert any(p["number"] == pr["number"] for p in got) + + +# --- targeted label add/remove ---------------------------------------------- + +def test_targeted_label_add_and_remove(gitea, seed_repo, issues): + labels = {} + for name, color in (("keep-me", "#00ff00"), ("drop-me", "#ff0000")): + labels[name] = api_request( + "POST", f"{seed_repo['api']}/labels", gitea["auth"], + payload={"name": name, "color": color}) + issue_no = issues[0]["number"] + issue_labels_url = f"{seed_repo['api']}/issues/{issue_no}/labels" + api_request("POST", issue_labels_url, gitea["auth"], + payload={"labels": [labels["keep-me"]["id"], + labels["drop-me"]["id"]]}) + # Targeted removal of one label must not disturb the other. + api_request("DELETE", f"{issue_labels_url}/{labels['drop-me']['id']}", + gitea["auth"]) + remaining = {l["name"] for l in api_request("GET", issue_labels_url, + gitea["auth"])} + assert "keep-me" in remaining + assert "drop-me" not in remaining + + +# --- permission denial / fail-closed ----------------------------------------- + +def test_bad_token_fails_closed_without_leaking_it(gitea): + bogus = "token 0123456789abcdef0123456789abcdef01234567" + with pytest.raises(RuntimeError) as exc: + api_request("GET", f"{gitea['base']}/api/v1/user", bogus) + msg = str(exc.value) + assert "401" in msg + assert "0123456789abcdef" not in msg # credential never echoed + + +def test_real_error_payload_surfaces_safely(gitea, seed_repo): + # A genuinely missing resource: Gitea's real 404 error payload must become + # a clear RuntimeError, not a stack trace, and must not echo the token. + with pytest.raises(RuntimeError) as exc: + api_request("GET", f"{seed_repo['api']}/issues/999999", gitea["auth"]) + msg = str(exc.value) + assert "404" in msg + token = os.environ.get("GITEA_INTEGRATION_TOKEN", "") + if token: + assert token not in msg