diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ace04fa..2aaa58b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -420,6 +420,16 @@ const baseUrl = ref(""); const savingBaseUrl = ref(false); const baseUrlSaved = ref(false); +// Git forge integration (admin only, #2689). The token round-trips masked; +// the server treats the mask as "unchanged". +const forge = ref({ kind: "", base_url: "", token: "" }); +const forgeKinds = ref(["gitea"]); +const forgeConfigured = ref(false); +const savingForge = ref(false); +const forgeSaved = ref(false); +const testingForge = ref(false); +const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null); + // Search test (SearXNG) const searxngConfigured = ref(false); @@ -565,10 +575,25 @@ onMounted(async () => { } catch { // base URL not configured yet } + try { + await loadForgeSettings(); + } catch { + // forge not configured yet + } } _loadTabContent(activeTab.value); }); +async function loadForgeSettings() { + const cfg = await apiGet<{ + kind: string; base_url: string; token: string; + configured: boolean; kinds: string[]; + }>("/api/admin/forge"); + forge.value = { kind: cfg.kind, base_url: cfg.base_url, token: cfg.token }; + forgeConfigured.value = cfg.configured; + if (cfg.kinds?.length) forgeKinds.value = cfg.kinds; +} + async function changeEmail() { changingEmail.value = true; try { @@ -734,6 +759,45 @@ async function sendTestEmail() { } } +async function saveForge() { + savingForge.value = true; + forgeSaved.value = false; + forgeTestResult.value = null; + try { + await apiPut("/api/admin/forge", forge.value); + await loadForgeSettings(); + forgeSaved.value = true; + setTimeout(() => (forgeSaved.value = false), 2000); + } catch (e) { + const body = (e as { body?: { error?: string } }).body; + toastStore.show(body?.error || "Failed to save forge settings", "error"); + } finally { + savingForge.value = false; + } +} + +async function testForge() { + testingForge.value = true; + forgeTestResult.value = null; + try { + const res = await apiPost<{ version: string; username: string }>( + "/api/admin/forge/test", {}, + ); + forgeTestResult.value = { + ok: true, + message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`, + }; + } catch (e) { + const body = (e as { body?: { error?: string } }).body; + forgeTestResult.value = { + ok: false, + message: body?.error || "Connection test failed", + }; + } finally { + testingForge.value = false; + } +} + async function saveBaseUrl() { savingBaseUrl.value = true; baseUrlSaved.value = false; @@ -2090,6 +2154,47 @@ function formatUserDate(iso: string): string { +
+

Git Forge

+

+ Optional read-only connection to your git forge (Gitea) so snippet + code can be fetched and drift-checked server-side. A read-scope API + token is enough. Leave the kind unset to keep the integration off. +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + Saved! +
+

+ {{ forgeTestResult.message }} +

+
+ diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index b14813b..a8ca2ec 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -111,10 +111,13 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then if (t != "") print "sym\t" t; next } # Keyword-announced definitions, functions and named types alike. + # Dunders are skipped: every class defines __init__, so "already defined + # in N other files" is guaranteed noise for them — and noise is what + # teaches sessions to skip the hint. if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) { t = line; sub(/^[a-z]+[[:space:]]+/, "", t) sub(/[^A-Za-z0-9_$].*$/, "", t) - if (t != "") print "sym\t" t; next + if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next } # Arrow/expression assignment: const name = (…) / let name = async ( if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) { diff --git a/src/scribe/config.py b/src/scribe/config.py index a72a88d..066ee92 100644 --- a/src/scribe/config.py +++ b/src/scribe/config.py @@ -60,6 +60,15 @@ class Config: # the MCP layer doesn't proxy web search (Claude has its own). SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "") + # Git forge integration (#2689) — optional read access to the operator's + # forge so snippet bodies can be fetched/verified server-side. Normally + # configured in Settings → Config (stored as admin settings); these env + # fallbacks exist so a deployment can keep the token in a Docker secret + # instead of the database. DB value wins when both are set. + FORGE_KIND: str = os.environ.get("FORGE_KIND", "") + FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/") + FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "") + @classmethod def oidc_enabled(cls) -> bool: return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET) diff --git a/src/scribe/routes/admin.py b/src/scribe/routes/admin.py index 1a5d0d7..e7b0a05 100644 --- a/src/scribe/routes/admin.py +++ b/src/scribe/routes/admin.py @@ -19,6 +19,15 @@ from scribe.services.backup import ( restore_full_backup, ) from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email +from scribe.services.forge import ( + FORGE_BASE_URL_KEY, + FORGE_KIND_KEY, + FORGE_KINDS, + FORGE_TOKEN_KEY, + ForgeError, + forge_config, + get_forge, +) from scribe.services.logging import get_logs, get_log_stats, log_audit from scribe.services.notifications import send_invitation_email from scribe.services.settings import ( @@ -157,6 +166,75 @@ async def test_smtp(): return jsonify({"error": str(e)}), 500 +_TOKEN_MASK = "********" + + +@admin_bp.route("/forge", methods=["GET"]) +@admin_required +async def get_forge_settings(): + cfg = await forge_config() + return jsonify({ + "kind": cfg["kind"], + "base_url": cfg["base_url"], + # The token itself never leaves the server — the smtp_password + # convention: masked when set, empty when not. + "token": _TOKEN_MASK if cfg["token"] else "", + "configured": bool(await get_forge()), + "kinds": list(FORGE_KINDS), + }) + + +@admin_bp.route("/forge", methods=["PUT"]) +@admin_required +async def update_forge_settings(): + data = await request.get_json() or {} + uid = get_current_user_id() + + kind = str(data.get("kind", "")).strip().lower() + if kind and kind not in FORGE_KINDS: + return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400 + base_url = str(data.get("base_url", "")).strip().rstrip("/") + if base_url and not base_url.startswith(("http://", "https://")): + return jsonify({"error": "Forge base URL must use http or https"}), 400 + + await set_admin_setting(FORGE_KIND_KEY, kind) + await set_admin_setting(FORGE_BASE_URL_KEY, base_url) + token = data.get("token") + # The mask coming back means "unchanged" — the form round-trips what GET + # showed it, and storing the mask would silently break the integration. + if token is not None and token != _TOKEN_MASK: + await set_admin_setting(FORGE_TOKEN_KEY, str(token)) + # The token is deliberately absent from the audit detail. + await log_audit( + "forge_config", user_id=uid, username=g.user.username, + ip_address=request.remote_addr, + details={"kind": kind, "base_url": base_url}, + ) + return jsonify({"status": "ok"}) + + +@admin_bp.route("/forge/test", methods=["POST"]) +@admin_required +async def test_forge(): + """Probe the SAVED forge config: reachability and token acceptance in one + press, so a misconfiguration is visible now rather than as silent + fallbacks later (#2663's lesson, applied to integrations).""" + uid = get_current_user_id() + forge = await get_forge() + if forge is None: + return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400 + try: + result = await forge.check() + except ForgeError as e: + return jsonify({"error": str(e)}), 502 + await log_audit( + "forge_test", user_id=uid, username=g.user.username, + ip_address=request.remote_addr, + details={"ok": True, "username": result.get("username", "")}, + ) + return jsonify(result) + + @admin_bp.route("/logs", methods=["GET"]) @admin_required async def list_logs(): diff --git a/src/scribe/routes/settings.py b/src/scribe/routes/settings.py index cbf9b28..850a8d4 100644 --- a/src/scribe/routes/settings.py +++ b/src/scribe/routes/settings.py @@ -16,13 +16,27 @@ logger = logging.getLogger(__name__) settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") +# Keys whose values are credentials. The admin endpoints that own them mask on +# read and skip the mask on write; this generic KV surface has to apply the +# same treatment, or it silently un-masks what those endpoints masked — the +# rows live on the admin's own user_id, so the plain GET returned them raw. +_SECRET_KEYS = frozenset({"smtp_password", "forge_token"}) +_SECRET_MASK = "********" + + +def _masked(settings: dict) -> dict: + return { + k: (_SECRET_MASK if k in _SECRET_KEYS and v else v) + for k, v in settings.items() + } + @settings_bp.route("", methods=["GET"]) @login_required async def get_settings_route(): uid = get_current_user_id() settings = await get_all_settings(uid) - return jsonify(settings) + return jsonify(_masked(settings)) @settings_bp.route("", methods=["PUT"]) @@ -36,6 +50,10 @@ async def update_settings_route(): to_save = {} for k, v in data.items(): str_v = str(v) + # A masked secret round-tripping through a client is "unchanged", not + # a request to store the mask over the real credential. + if k in _SECRET_KEYS and str_v == _SECRET_MASK: + continue if not str_v: await delete_setting(uid, k) else: @@ -45,7 +63,7 @@ async def update_settings_route(): await set_settings_batch(uid, to_save) settings = await get_all_settings(uid) - return jsonify(settings) + return jsonify(_masked(settings)) @settings_bp.route("/search", methods=["GET"]) diff --git a/src/scribe/services/forge.py b/src/scribe/services/forge.py new file mode 100644 index 0000000..2b7c51a --- /dev/null +++ b/src/scribe/services/forge.py @@ -0,0 +1,237 @@ +"""Forge adapter — optional server-side READ access to the operator's git forge. + +Step 4 of milestone 288 (#2689, decision #2686). The recorded location of a +snippet is the source of truth for its code and the stored body is a cache; +this module is the seam that lets the SERVER read that source of truth, so the +cache can be refreshed at pull time (step 5), drift can be flagged from push +webhooks (step 6), and coverage can be measured (step 7). + +Design constraints, in force everywhere below: + + - OPTIONAL per instance (rule #115). `get_forge()` returns None when nothing + is configured, and every consumer must treat None as "keep today's + behavior". An install that never configures a forge is not degraded — it + is the baseline. + - READ-ONLY by construction. The adapter exposes reads; there is no write + method to misuse. The token an operator mints for it only ever needs read + scope, and the docs say so. + - The contract stays as small as its consumers (steps 5-7): read_file / + default_branch / resolve_repo / check. GitHub later implements this same + contract (step 8); resist widening it speculatively. + - Repo identity is the repo-binding key — `normalize_repo_key`'s + host/owner/repo — so the join between a snippet's recorded repo and the + forge needs no new identity scheme. The host segment selects whether THIS + forge can serve the repo; the remainder is the API path. + - Errors carry no token, ever, and failures are exceptions the caller + handles — a consumer decides whether to fall back (pull-time fetch) or + surface (settings test button); this module never silently swallows. + +This is also the codebase's first outbound-HTTP client with a real timeout +convention (oauth.py predates it): short total timeout, no retries — every +consumer has a fallback, so a slow forge must cost bounded time. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +from dataclasses import dataclass +from urllib.parse import quote, urlsplit + +import httpx + +from scribe.config import Config +from scribe.services.repo_bindings import normalize_repo_key +from scribe.services.settings import get_admin_setting + +logger = logging.getLogger(__name__) + +FORGE_KIND_KEY = "forge_kind" +FORGE_BASE_URL_KEY = "forge_base_url" +FORGE_TOKEN_KEY = "forge_token" + +# Kinds an instance can configure. GitHub joins in step 8 of milestone 288. +FORGE_KINDS = ("gitea",) + +# Total budget per forge call. Consumers either have a cache to fall back to +# (step 5) or a user watching a button (the test probe) — neither tolerates a +# hung socket, and there is no retry: the fallback IS the retry policy. +_TIMEOUT = httpx.Timeout(5.0) + + +class ForgeError(RuntimeError): + """A forge call failed (network, auth, unexpected payload). Token-free.""" + + +class ForgeNotFound(ForgeError): + """The repo, path, or ref does not exist on the forge — the one failure + consumers treat differently, because for a recorded snippet location it is + itself a finding (the recorded path is gone).""" + + +@dataclass(frozen=True) +class ForgeFile: + """One file read from the forge at a specific point in history.""" + + content: str + # The commit the content was served at — what provenance stores (#2688). + commit_sha: str + path: str + + +def _host_of(url: str) -> str: + return (urlsplit(url).hostname or "").lower() + + +class GiteaForge: + """The Gitea implementation of the forge contract, over its REST API. + + `transport` exists for tests: httpx.MockTransport makes the contract + testable without a live server or a new dependency. Production callers + never pass it. + """ + + kind = "gitea" + + def __init__(self, base_url: str, token: str, *, transport=None) -> None: + self.base_url = (base_url or "").rstrip("/") + self._token = token or "" + self._transport = transport + + @property + def host(self) -> str: + return _host_of(self.base_url) + + def resolve_repo(self, repo_or_url: str) -> str | None: + """The forge-API repo path for a recorded repo — or None if this forge + does not serve it. + + Accepts anything `normalize_repo_key` accepts (a raw remote URL or an + already-normalized key). None is a NORMAL answer, not an error: a + snippet recorded against github.com on an instance whose forge is a + self-hosted Gitea is simply out of this forge's reach. + """ + key = normalize_repo_key(repo_or_url or "") + if not key or "/" not in key: + return None + host, _, rest = key.partition("/") + if host != self.host or "/" not in rest: + return None + return rest + + def _client(self) -> httpx.AsyncClient: + kwargs: dict = { + "base_url": f"{self.base_url}/api/v1", + "headers": {"Authorization": f"token {self._token}"}, + "timeout": _TIMEOUT, + } + if self._transport is not None: + kwargs["transport"] = self._transport + return httpx.AsyncClient(**kwargs) + + async def _get(self, client: httpx.AsyncClient, url: str, **kw) -> httpx.Response: + try: + resp = await client.get(url, **kw) + except httpx.HTTPError as exc: + # str(exc) on transport errors names hosts and timeouts, never + # headers — safe, and the detail is what makes the test button useful. + raise ForgeError(f"forge unreachable: {exc}") from exc + if resp.status_code == 404: + raise ForgeNotFound(f"not found on forge: {url}") + if resp.status_code in (401, 403): + raise ForgeError("forge rejected the token (check its read scope)") + if resp.status_code >= 400: + raise ForgeError(f"forge returned HTTP {resp.status_code} for {url}") + return resp + + async def read_file(self, repo: str, path: str, ref: str = "") -> ForgeFile: + """Read one file's current content, with the commit it was served at. + + `repo` is the API path from resolve_repo ("owner/repo"); `ref` is a + branch, tag, or commit — empty means the default branch. + """ + params = {"ref": ref} if ref else None + async with self._client() as client: + resp = await self._get( + client, + f"/repos/{repo}/contents/{quote(path, safe='/')}", + params=params, + ) + payload = resp.json() + if isinstance(payload, list): + raise ForgeNotFound(f"{path} is a directory on the forge, not a file") + if payload.get("type") != "file": + raise ForgeNotFound( + f"{path} is a {payload.get('type', 'non-file')} on the forge" + ) + if payload.get("encoding") != "base64" or payload.get("content") is None: + raise ForgeError(f"forge returned no readable content for {path}") + try: + content = base64.b64decode(payload["content"]).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise ForgeError(f"forge content for {path} is not utf-8 text") from exc + return ForgeFile( + content=content, + # last_commit_sha is the commit that last touched the file — the + # honest provenance stamp. The blob sha is a content address, not + # a point in history, so it is deliberately not surfaced. + commit_sha=payload.get("last_commit_sha") or "", + path=payload.get("path") or path, + ) + + async def default_branch(self, repo: str) -> str: + async with self._client() as client: + resp = await self._get(client, f"/repos/{repo}") + branch = (resp.json() or {}).get("default_branch") or "" + if not branch: + raise ForgeError(f"forge reported no default branch for {repo}") + return branch + + async def check(self) -> dict: + """Health probe for the settings test button: reach the forge AND + prove the token is accepted. Returns {"ok", "version", "username"}.""" + async with self._client() as client: + version = (await self._get(client, "/version")).json() or {} + user = (await self._get(client, "/user")).json() or {} + return { + "ok": True, + "version": version.get("version") or "", + "username": user.get("login") or user.get("username") or "", + } + + +async def forge_config() -> dict: + """The instance's forge configuration, DB-first with env fallback. + + The env channel exists so a deployment can keep the token out of the + database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins + when both are present because the admin UI writes there, and a UI edit + that silently loses to an env var would look exactly like a broken form. + """ + return { + "kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND) + .strip() + .lower(), + "base_url": ( + await get_admin_setting(FORGE_BASE_URL_KEY, "") or Config.FORGE_BASE_URL + ).rstrip("/"), + "token": await get_admin_setting(FORGE_TOKEN_KEY, "") or Config.FORGE_TOKEN, + } + + +async def get_forge(*, transport=None) -> GiteaForge | None: + """The configured forge adapter, or None — and None means "behave exactly + as if this module did not exist", which every consumer must honor.""" + cfg = await forge_config() + if cfg["kind"] not in FORGE_KINDS: + if cfg["kind"]: + # A kind we don't implement is a misconfiguration, not "off" — + # say so once per lookup rather than silently reading as absent. + logger.warning("unknown forge kind %r configured — forge disabled", cfg["kind"]) + return None + if not cfg["base_url"] or not cfg["token"]: + return None + if not cfg["base_url"].startswith(("http://", "https://")): + logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"]) + return None + return GiteaForge(cfg["base_url"], cfg["token"], transport=transport) diff --git a/tests/test_services_forge.py b/tests/test_services_forge.py new file mode 100644 index 0000000..473111f --- /dev/null +++ b/tests/test_services_forge.py @@ -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)