feat: opt-in Docker-based Gitea integration test suite (#66)

Adds tests/integration/: an optional real-Gitea suite, skipped by default,
enabled only by GITEA_INTEGRATION=1.

- docker-compose.yml pins gitea/gitea:1.22.6 (disposable container, port 3003)
- gitea-integration helper: up (wait-ready) / token (test-only admin, token to
  stdout only) / down (removes container + volume)
- conftest.py: session fixtures; unique disposable seed repo (inttest-<hex>)
  created via API and deleted on teardown
- test_gitea_live.py (6 tests, via shared api_request/api_get_all client):
  issue pagination multi-page walk + overall limit, PR listing, targeted label
  add/remove leaves other labels intact, bad-token 401 fails closed without
  echoing the credential, real 404 payload surfaces as safe redacted error
- README + developer-testing-guidelines section 8 updated (planned -> real)

Default pytest tests/ -q: 355 passed, 6 skipped (unit suite unchanged, no
network). Live verification: 6 passed against the pinned container. No
production credentials; token never logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:11:48 -04:00
parent 02c0c2023b
commit 22347bd549
7 changed files with 327 additions and 8 deletions
+9 -8
View File
@@ -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
+60
View File
@@ -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).
View File
+65
View File
@@ -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
+19
View File
@@ -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:
+59
View File
@@ -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
+115
View File
@@ -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