feat(forge): adapter seam + Gitea implementation — optional read access to the operator's forge (#2689)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s
Step 4 of milestone 288 (decision #2686). services/forge.py defines the contract steps 5-7 consume — read_file (content + last_commit_sha, the provenance stamp), default_branch, resolve_repo, check — with GiteaForge as the first implementation over the REST contents/repo/version/user endpoints. Repo identity reuses normalize_repo_key: the host segment selects whether this forge serves a recorded repo, the remainder is the API path, so no new identity scheme exists. Read-only by construction; errors never carry the token; first outbound-HTTP timeout convention (5s total, no retries — the consumer's fallback is the retry policy). OPTIONAL per instance (rule #115): get_forge() returns None when unconfigured and every consumer treats None as today's behavior. Config lives in admin settings (Settings → Config → Git Forge: kind/base URL/token, save + test-connection probe reporting version + identity), with FORGE_* env / Docker-secret fallbacks; DB wins so a UI edit can't silently lose to an env var. Token treatment follows the smtp_password convention (masked on read, mask-sentinel skipped on write, absent from audit details) — and wiring it surfaced that the generic GET/PUT /api/settings dump bypassed that masking for the owning admin's raw KV rows, so secret keys are now masked there too (fixes the same exposure for smtp_password). Contract tests run against httpx.MockTransport as the fake forge — the reference behaviors the GitHub adapter (step 8) must reproduce — plus the off-by-default gate, partial-config-is-off, env-vs-DB precedence, and route/mask structural checks. Also: the step-2 definition detector learned to skip dunders after flagging __init__ as 'already defined in 4 files' on this step's own build — guaranteed noise for a hint that must stay trustworthy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Forge adapter contract tests (#2689) — the Gitea implementation against a
|
||||
mocked transport, plus the configuration gate.
|
||||
|
||||
httpx.MockTransport is the fake forge: the adapter takes an injectable
|
||||
transport precisely so the CONTRACT (URLs hit, auth header shape, payload
|
||||
decoding, error taxonomy) is testable with no live server and no new
|
||||
dependency. These are the reference behaviors step 8's GitHub adapter must
|
||||
reproduce.
|
||||
|
||||
The most load-bearing tests are the OFF ones: an unconfigured instance must
|
||||
get None from get_forge(), because every consumer treats None as "behave as if
|
||||
the module didn't exist" (rule #115 — the baseline install has no forge).
|
||||
"""
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from scribe.services.forge import (
|
||||
ForgeError,
|
||||
ForgeNotFound,
|
||||
GiteaForge,
|
||||
get_forge,
|
||||
)
|
||||
|
||||
BASE = "https://git.example.com"
|
||||
|
||||
|
||||
def _forge(handler) -> GiteaForge:
|
||||
return GiteaForge(BASE, "tok-123", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _json(status: int, payload) -> httpx.Response:
|
||||
return httpx.Response(status, json=payload)
|
||||
|
||||
|
||||
# --- resolve_repo: the join between recorded repos and this forge ------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recorded", "expected"),
|
||||
[
|
||||
("https://git.example.com/alice/Widget.git", "alice/widget"),
|
||||
("git@git.example.com:alice/widget.git", "alice/widget"),
|
||||
("git.example.com/alice/widget", "alice/widget"),
|
||||
# Nested (GitLab-style) groups survive as the API path remainder.
|
||||
("https://git.example.com/team/sub/widget", "team/sub/widget"),
|
||||
# Another host is a NORMAL miss, not an error.
|
||||
("https://github.com/alice/widget", None),
|
||||
("", None),
|
||||
("not a url", None),
|
||||
# Host alone, no owner/repo remainder.
|
||||
("git.example.com", None),
|
||||
],
|
||||
)
|
||||
def test_resolve_repo_matches_by_host_and_yields_the_api_path(recorded, expected):
|
||||
forge = GiteaForge(BASE, "tok")
|
||||
assert forge.resolve_repo(recorded) == expected
|
||||
|
||||
|
||||
# --- read_file ---------------------------------------------------------------
|
||||
|
||||
async def test_read_file_decodes_content_and_carries_the_commit():
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("Authorization")
|
||||
return _json(200, {
|
||||
"type": "file",
|
||||
"encoding": "base64",
|
||||
"content": base64.b64encode("def x():\n return 1\n".encode()).decode(),
|
||||
"sha": "blob" * 10,
|
||||
"last_commit_sha": "c" * 40,
|
||||
"path": "src/x.py",
|
||||
})
|
||||
|
||||
got = await _forge(handler).read_file("alice/widget", "src/x.py", ref="dev")
|
||||
assert got.content == "def x():\n return 1\n"
|
||||
assert got.commit_sha == "c" * 40
|
||||
assert got.path == "src/x.py"
|
||||
assert "/api/v1/repos/alice/widget/contents/src/x.py" in seen["url"]
|
||||
assert "ref=dev" in seen["url"]
|
||||
assert seen["auth"] == "token tok-123"
|
||||
|
||||
|
||||
async def test_read_file_404_is_not_found_and_a_directory_is_too():
|
||||
with pytest.raises(ForgeNotFound):
|
||||
await _forge(lambda r: _json(404, {"message": "no"})).read_file(
|
||||
"alice/widget", "gone.py"
|
||||
)
|
||||
# The contents API returns a LIST for a directory — that's "no such file",
|
||||
# not a decoding error.
|
||||
with pytest.raises(ForgeNotFound):
|
||||
await _forge(lambda r: _json(200, [{"type": "file"}])).read_file(
|
||||
"alice/widget", "src"
|
||||
)
|
||||
|
||||
|
||||
async def test_read_file_auth_failure_names_the_scope_never_the_token():
|
||||
with pytest.raises(ForgeError) as err:
|
||||
await _forge(lambda r: _json(401, {})).read_file("alice/widget", "x.py")
|
||||
assert "tok-123" not in str(err.value)
|
||||
assert "scope" in str(err.value)
|
||||
|
||||
|
||||
async def test_read_file_binary_content_is_a_forge_error():
|
||||
def handler(request):
|
||||
return _json(200, {
|
||||
"type": "file", "encoding": "base64",
|
||||
"content": base64.b64encode(b"\xff\xfe\x00\x01").decode(),
|
||||
})
|
||||
|
||||
with pytest.raises(ForgeError):
|
||||
await _forge(handler).read_file("alice/widget", "img.bin")
|
||||
|
||||
|
||||
async def test_unreachable_forge_is_a_forge_error_not_a_crash():
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
with pytest.raises(ForgeError):
|
||||
await _forge(handler).read_file("alice/widget", "x.py")
|
||||
|
||||
|
||||
# --- default_branch / check --------------------------------------------------
|
||||
|
||||
async def test_default_branch_reads_the_repo_record():
|
||||
forge = _forge(lambda r: _json(200, {"default_branch": "dev"}))
|
||||
assert await forge.default_branch("alice/widget") == "dev"
|
||||
|
||||
|
||||
async def test_check_reports_version_and_identity():
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/version"):
|
||||
return _json(200, {"version": "1.23.1"})
|
||||
return _json(200, {"login": "scribe-bot"})
|
||||
|
||||
result = await _forge(handler).check()
|
||||
assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"}
|
||||
|
||||
|
||||
# --- the configuration gate --------------------------------------------------
|
||||
|
||||
def _settings(values: dict):
|
||||
async def fake(key, default=""):
|
||||
return values.get(key, default)
|
||||
return patch("scribe.services.forge.get_admin_setting", AsyncMock(side_effect=fake))
|
||||
|
||||
|
||||
async def test_unconfigured_instance_gets_none():
|
||||
with _settings({}), patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
|
||||
assert await get_forge() is None
|
||||
|
||||
|
||||
async def test_partial_config_is_still_off():
|
||||
# A base URL with no token (or vice versa) must not half-enable anything.
|
||||
for values in (
|
||||
{"forge_kind": "gitea", "forge_base_url": BASE},
|
||||
{"forge_kind": "gitea", "forge_token": "tok"},
|
||||
{"forge_base_url": BASE, "forge_token": "tok"}, # no kind selected
|
||||
):
|
||||
with _settings(values), patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
|
||||
assert await get_forge() is None
|
||||
|
||||
|
||||
async def test_unknown_kind_disables_with_a_warning_not_a_crash():
|
||||
with _settings({
|
||||
"forge_kind": "sourcehut", "forge_base_url": BASE, "forge_token": "tok",
|
||||
}), patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
|
||||
assert await get_forge() is None
|
||||
|
||||
|
||||
async def test_full_config_builds_a_gitea_adapter():
|
||||
with _settings({
|
||||
"forge_kind": "gitea",
|
||||
"forge_base_url": BASE + "/", # trailing slash normalized away
|
||||
"forge_token": "tok",
|
||||
}), patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
|
||||
forge = await get_forge()
|
||||
assert isinstance(forge, GiteaForge)
|
||||
assert forge.base_url == BASE
|
||||
assert forge.host == "git.example.com"
|
||||
|
||||
|
||||
async def test_env_channel_fills_gaps_but_db_wins():
|
||||
# Docker-secret deployments set FORGE_* env; an admin-UI value overrides.
|
||||
with _settings({"forge_base_url": "https://db.example.com"}), \
|
||||
patch("scribe.services.forge.Config") as cfg:
|
||||
cfg.FORGE_KIND = "gitea"
|
||||
cfg.FORGE_BASE_URL = "https://env.example.com"
|
||||
cfg.FORGE_TOKEN = "env-tok"
|
||||
forge = await get_forge()
|
||||
assert isinstance(forge, GiteaForge)
|
||||
assert forge.host == "db.example.com"
|
||||
|
||||
|
||||
def test_forge_error_taxonomy_is_catchable_as_one_family():
|
||||
assert issubclass(ForgeNotFound, ForgeError)
|
||||
assert issubclass(ForgeError, RuntimeError)
|
||||
|
||||
|
||||
def test_adapter_contract_surface():
|
||||
"""Step 8's GitHub adapter implements exactly this surface — pin it."""
|
||||
for method in ("read_file", "default_branch", "resolve_repo", "check"):
|
||||
assert callable(getattr(GiteaForge, method))
|
||||
assert GiteaForge.kind == "gitea"
|
||||
|
||||
|
||||
def test_admin_routes_registered():
|
||||
from scribe.app import create_app
|
||||
from scribe.routes import admin as admin_routes
|
||||
|
||||
for name in ("get_forge_settings", "update_forge_settings", "test_forge"):
|
||||
assert callable(getattr(admin_routes, name))
|
||||
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
||||
assert "/api/admin/forge" in rules
|
||||
assert "/api/admin/forge/test" in rules
|
||||
|
||||
|
||||
def test_settings_kv_surface_masks_the_forge_token():
|
||||
"""The generic /api/settings dump masked nothing — the admin endpoints'
|
||||
masking was bypassable by reading the raw KV rows (found while wiring the
|
||||
forge token; smtp_password had the same exposure)."""
|
||||
from scribe.routes.settings import _SECRET_KEYS, _masked
|
||||
|
||||
out = _masked({"forge_token": "tok-123", "smtp_password": "pw", "theme": "dark"})
|
||||
assert out["forge_token"] == "********"
|
||||
assert out["smtp_password"] == "********"
|
||||
assert out["theme"] == "dark"
|
||||
assert {"forge_token", "smtp_password"} <= set(_SECRET_KEYS)
|
||||
# An unset secret stays empty rather than reading as a set-but-masked one.
|
||||
assert _masked({"forge_token": ""})["forge_token"] == ""
|
||||
|
||||
|
||||
def test_config_has_the_docker_secret_channel():
|
||||
from scribe.config import Config
|
||||
for attr in ("FORGE_KIND", "FORGE_BASE_URL", "FORGE_TOKEN"):
|
||||
assert hasattr(Config, attr)
|
||||
Reference in New Issue
Block a user