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:
@@ -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).
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Executable
+59
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user