feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single admin-settings config is replaced by per-user keyring rows (one per forge host), and every server-side forge read runs on the PROJECT OWNER's keyring: - forge_connections table + projects.forge_connection_id pin (migration 0078, which also carries the existing admin config into the first admin's row and deletes the old setting keys — no legacy dual-read) - get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector; resolve(repo) picks the connection whose host serves the repo. A pinned project uses ONLY its pinned connection; a stale pin (ownership moved) is ignored, never honored across users - env FORGE_* config survives as an implicit entry for admin owners only; a stored row for the same host beats it - consumers threaded: pull-time freshness (owner of the note), coverage (owner of the project), coverage routes' configured flag - routes: /api/settings/forge-connections CRUD + per-connection test (own-rows only, tokens never returned); /api/admin/forge shrinks to /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins, owner-or-admin asking, owner's connections only - UI: Git Forges card moves to Settings -> Integrations as a connection list; webhook secret stays in the admin Config tab; owner-only forge select on the project coverage card - backups exclude forge_connections (credentials, api_keys precedent) and the pin, so restores fall back to keyring resolution Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Real-Postgres integration tests for the forge keyring (#2778).
|
||||
|
||||
Runs only in the CI integration lane (real Postgres, schema built by
|
||||
`alembic upgrade head`, which includes migration 0078's forge_connections
|
||||
table and the projects.forge_connection_id pin). This exercises what the
|
||||
unit tests cannot: the own-rows ACL on connection CRUD, host-keyed
|
||||
resolution against stored rows, the pin's only-that-connection semantics,
|
||||
the owner-only pin eligibility, and the admin-only scoping of the env
|
||||
fallback — every one of which is a cross-user isolation property, and
|
||||
isolation properties are exactly what mocks cannot prove.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.config import Config
|
||||
from scribe.models import async_session, engine
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.user import User
|
||||
from scribe.services.forge import get_forges
|
||||
from scribe.services.forge_connections import (
|
||||
create_connection,
|
||||
delete_connection,
|
||||
list_connections,
|
||||
set_project_pin,
|
||||
update_connection,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
GITEA = "https://git.example.com"
|
||||
GITHUB = "https://github.com"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _dispose_engine():
|
||||
# Per-loop pool: dispose after each test (see test_integration_db_maintenance).
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _user(session, username: str, role: str = "user") -> User:
|
||||
existing = (
|
||||
await session.execute(select(User).where(User.username == username))
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
user = User(username=username, role=role)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded():
|
||||
"""An owner with a project, plus an unrelated user and an admin."""
|
||||
async with async_session() as s:
|
||||
owner = await _user(s, "keyring_owner")
|
||||
other = await _user(s, "keyring_other")
|
||||
admin = await _user(s, "keyring_admin", role="admin")
|
||||
project = Project(user_id=owner.id, title="Keyring project")
|
||||
s.add(project)
|
||||
await s.flush()
|
||||
ids = {
|
||||
"owner": owner.id, "other": other.id, "admin": admin.id,
|
||||
"project": project.id,
|
||||
}
|
||||
await s.commit()
|
||||
# Start each test from a clean keyring — the fixture users persist
|
||||
# across tests in one lane run.
|
||||
for uid in (ids["owner"], ids["other"], ids["admin"]):
|
||||
for row in await list_connections(uid):
|
||||
await delete_connection(uid, row.id)
|
||||
return ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_crud_is_own_rows_only(seeded):
|
||||
owner, other = seeded["owner"], seeded["other"]
|
||||
row = await create_connection(owner, kind="gitea", base_url=GITEA, token="tok")
|
||||
assert row.host == "git.example.com"
|
||||
|
||||
# One row per (user, host) IS the resolution model.
|
||||
with pytest.raises(ValueError):
|
||||
await create_connection(owner, kind="gitea", base_url=GITEA + "/", token="t2")
|
||||
# ...but the same host under ANOTHER user is that user's own business.
|
||||
theirs = await create_connection(other, kind="gitea", base_url=GITEA, token="t3")
|
||||
|
||||
# Another user can neither see, edit, nor delete it.
|
||||
assert [r.id for r in await list_connections(other)] == [theirs.id]
|
||||
assert await update_connection(other, row.id, token="stolen") is None
|
||||
assert await delete_connection(other, row.id) is False
|
||||
|
||||
updated = await update_connection(owner, row.id, base_url=GITEA + ":3000")
|
||||
assert updated is not None and updated.base_url.endswith(":3000")
|
||||
assert await delete_connection(owner, row.id) is True
|
||||
assert await delete_connection(other, theirs.id) is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_resolution_runs_on_the_owners_keyring_only(seeded):
|
||||
owner, other = seeded["owner"], seeded["other"]
|
||||
await create_connection(owner, kind="gitea", base_url=GITEA, token="tok")
|
||||
|
||||
mine = await get_forges(owner)
|
||||
assert mine.configured
|
||||
hit = mine.resolve(f"{GITEA}/alice/widget.git")
|
||||
assert hit is not None and hit[1] == "alice/widget"
|
||||
|
||||
# The other user's reads never ride the owner's token.
|
||||
assert not (await get_forges(other)).configured
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_pin_means_only_that_connection(seeded):
|
||||
owner, project = seeded["owner"], seeded["project"]
|
||||
gitea = await create_connection(owner, kind="gitea", base_url=GITEA, token="t1")
|
||||
await create_connection(owner, kind="github", base_url=GITHUB, token="t2")
|
||||
|
||||
# Unpinned: both hosts resolve from the keyring.
|
||||
selector = await get_forges(owner, project)
|
||||
assert selector.resolve(f"{GITEA}/a/b") is not None
|
||||
assert selector.resolve(f"{GITHUB}/a/b") is not None
|
||||
|
||||
assert await set_project_pin(owner, project, gitea.id) is True
|
||||
pinned = await get_forges(owner, project)
|
||||
assert pinned.resolve(f"{GITEA}/a/b") is not None
|
||||
# The pin is exclusive: a host the pinned connection can't serve reads as
|
||||
# unserved, exactly like the documented repo-not-on-this-forge state.
|
||||
assert pinned.resolve(f"{GITHUB}/a/b") is None
|
||||
|
||||
assert await set_project_pin(owner, project, None) is True
|
||||
assert (await get_forges(owner, project)).resolve(f"{GITHUB}/a/b") is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_pin_only_accepts_the_owners_connections(seeded):
|
||||
owner, other, project = seeded["owner"], seeded["other"], seeded["project"]
|
||||
theirs = await create_connection(other, kind="gitea", base_url=GITEA, token="t")
|
||||
|
||||
# A collaborator's token can never be attached to someone else's project —
|
||||
# the confused-deputy channel this feature exists to close.
|
||||
assert await set_project_pin(owner, project, theirs.id) is False
|
||||
# And only the owner's projects accept a pin at all.
|
||||
assert await set_project_pin(other, project, theirs.id) is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_stale_pin_after_ownership_transfer_is_ignored_not_honored(seeded):
|
||||
owner, other, project = seeded["owner"], seeded["other"], seeded["project"]
|
||||
gitea = await create_connection(owner, kind="gitea", base_url=GITEA, token="t")
|
||||
assert await set_project_pin(owner, project, gitea.id) is True
|
||||
|
||||
async with async_session() as s:
|
||||
proj = await s.get(Project, project)
|
||||
proj.user_id = other
|
||||
await s.commit()
|
||||
try:
|
||||
# The pin now names a connection the (new) owner does not hold: it
|
||||
# must fall back to the new owner's keyring — empty — never keep
|
||||
# reading with the previous owner's token.
|
||||
assert not (await get_forges(other, project)).configured
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
proj = await s.get(Project, project)
|
||||
proj.user_id = owner
|
||||
proj.forge_connection_id = None
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_env_config_serves_admins_only_and_rows_beat_it(seeded):
|
||||
owner, admin = seeded["owner"], seeded["admin"]
|
||||
with patch.object(Config, "FORGE_KIND", "gitea"), \
|
||||
patch.object(Config, "FORGE_BASE_URL", GITEA), \
|
||||
patch.object(Config, "FORGE_TOKEN", "env-tok"):
|
||||
# The env entry is the OPERATOR's token: admin projects only.
|
||||
assert (await get_forges(admin)).configured
|
||||
assert not (await get_forges(owner)).configured
|
||||
|
||||
# A stored row for the same host wins — the UI writes rows, and a UI
|
||||
# edit that silently lost to an env var would look like a broken form.
|
||||
row = await create_connection(
|
||||
admin, kind="gitea", base_url=GITEA, token="row-tok"
|
||||
)
|
||||
selector = await get_forges(admin)
|
||||
assert len(selector.adapters) == 1
|
||||
assert selector.adapters[0]._token == "row-tok"
|
||||
await delete_connection(admin, row.id)
|
||||
@@ -194,6 +194,14 @@ def _forge(tar_bytes: bytes):
|
||||
)
|
||||
|
||||
|
||||
def _selector(tar_bytes: bytes):
|
||||
"""The keyring shape compute_coverage consumes since #2778 — one owner
|
||||
keyring holding the mocked Gitea adapter."""
|
||||
from scribe.services.forge import ForgeSelector
|
||||
|
||||
return ForgeSelector((_forge(tar_bytes),))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def _dispose_engine():
|
||||
from scribe.models import engine
|
||||
@@ -250,9 +258,9 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
|
||||
)
|
||||
|
||||
uid, pid = seeded["uid"], seeded["pid"]
|
||||
forge = _forge(_tarball(TREE))
|
||||
selector = _selector(_tarball(TREE))
|
||||
|
||||
coverage = await compute_coverage(uid, pid, forge=forge)
|
||||
coverage = await compute_coverage(uid, pid, selector=selector)
|
||||
assert coverage is not None
|
||||
assert coverage["total"] == 4
|
||||
assert coverage["recorded"] == 2
|
||||
@@ -266,7 +274,7 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
|
||||
# Nothing computed → nothing cached; refresh writes; the cache reads back
|
||||
# byte-equal, because enter_project will serve exactly this.
|
||||
assert await cached_coverage(uid, pid) is None
|
||||
stored = await refresh_coverage(uid, pid, forge=forge)
|
||||
stored = await refresh_coverage(uid, pid, selector=selector)
|
||||
assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored))
|
||||
|
||||
|
||||
@@ -284,7 +292,7 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
|
||||
before = await enter_project(project_id=pid)
|
||||
assert before["pattern_coverage"] is None
|
||||
|
||||
await refresh_coverage(uid, pid, forge=_forge(_tarball(TREE)))
|
||||
await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
after = await enter_project(project_id=pid)
|
||||
line = after["pattern_coverage"]
|
||||
assert line.startswith(
|
||||
@@ -314,4 +322,4 @@ async def test_unservable_binding_measures_nothing(seeded):
|
||||
await s.commit()
|
||||
await set_binding(uid, "https://github.com/somebody/else.git", other_pid)
|
||||
|
||||
assert await compute_coverage(uid, other_pid, forge=_forge(_tarball(TREE))) is None
|
||||
assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None
|
||||
|
||||
@@ -7,12 +7,13 @@ 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).
|
||||
The most load-bearing tests are the OFF ones: a user with no usable connection
|
||||
must get an empty selector, because every consumer treats that as "behave as
|
||||
if the module didn't exist" (rule #115 — the baseline user has no forge).
|
||||
DB-backed keyring behavior (get_forges: rows, the project pin, the admin-only
|
||||
env entry) lives in tests/test_integration_forge_keyring.py.
|
||||
"""
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -20,8 +21,9 @@ import pytest
|
||||
from scribe.services.forge import (
|
||||
ForgeError,
|
||||
ForgeNotFound,
|
||||
ForgeSelector,
|
||||
GiteaForge,
|
||||
get_forge,
|
||||
build_adapter,
|
||||
)
|
||||
|
||||
BASE = "https://git.example.com"
|
||||
@@ -140,63 +142,59 @@ async def test_check_reports_version_and_identity():
|
||||
assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"}
|
||||
|
||||
|
||||
# --- the configuration gate --------------------------------------------------
|
||||
# --- the configuration gate (build_adapter) ----------------------------------
|
||||
|
||||
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():
|
||||
def test_empty_or_partial_values_build_nothing():
|
||||
# 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
|
||||
assert build_adapter("", "", "") is None
|
||||
assert build_adapter("gitea", BASE, "") is None
|
||||
assert build_adapter("gitea", "", "tok") is None
|
||||
assert build_adapter("", BASE, "tok") is None # no kind selected
|
||||
|
||||
|
||||
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
|
||||
def test_unknown_kind_disables_with_a_warning_not_a_crash():
|
||||
assert build_adapter("sourcehut", BASE, "tok") 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()
|
||||
def test_schemeless_base_url_disables():
|
||||
assert build_adapter("gitea", "git.example.com", "tok") is None
|
||||
|
||||
|
||||
def test_full_values_build_a_gitea_adapter():
|
||||
forge = build_adapter("Gitea", BASE + "/", "tok") # case + slash normalized
|
||||
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_full_values_build_a_github_adapter():
|
||||
from scribe.services.forge import GitHubForge
|
||||
|
||||
assert isinstance(
|
||||
build_adapter("github", "https://github.com", "tok"), GitHubForge
|
||||
)
|
||||
|
||||
|
||||
# --- the selector (#2778): host-keyed resolution over a keyring ---------------
|
||||
|
||||
def test_empty_selector_is_the_rule_115_baseline():
|
||||
selector = ForgeSelector()
|
||||
assert not selector.configured
|
||||
assert selector.resolve(f"{BASE}/alice/widget") is None
|
||||
|
||||
|
||||
def test_selector_resolves_by_host_across_the_keyring():
|
||||
gitea = build_adapter("gitea", BASE, "tok")
|
||||
github = build_adapter("github", "https://github.com", "tok2")
|
||||
selector = ForgeSelector((gitea, github))
|
||||
assert selector.configured
|
||||
|
||||
hit = selector.resolve("https://git.example.com/alice/widget")
|
||||
assert hit == (gitea, "alice/widget")
|
||||
hit = selector.resolve("git@github.com:alice/Widget.git")
|
||||
assert hit == (github, "alice/widget")
|
||||
# A host neither connection serves is a NORMAL miss, not an error.
|
||||
assert selector.resolve("https://elsewhere.example.org/a/b") is None
|
||||
|
||||
|
||||
def test_forge_error_taxonomy_is_catchable_as_one_family():
|
||||
@@ -220,30 +218,50 @@ def test_adapter_contract_surface():
|
||||
assert set(FORGE_KINDS) == {"gitea", "github"}
|
||||
|
||||
|
||||
def test_admin_routes_registered():
|
||||
def test_forge_routes_registered_at_their_post_2778_homes():
|
||||
"""Connections are USER settings; only the webhook secret stays admin.
|
||||
The old instance-wide /api/admin/forge endpoints must be GONE, not
|
||||
coexisting — a legacy config surface that still wrote admin settings
|
||||
would silently configure nothing (rule #22: no dual path)."""
|
||||
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"):
|
||||
for name in ("get_forge_webhook_settings", "update_forge_webhook_settings"):
|
||||
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
|
||||
assert "/api/admin/forge-webhook" in rules
|
||||
assert "/api/admin/forge" not in rules
|
||||
assert "/api/admin/forge/test" not in rules
|
||||
assert "/api/settings/forge-connections" in rules
|
||||
assert "/api/settings/forge-connections/<int:connection_id>" in rules
|
||||
assert "/api/settings/forge-connections/<int:connection_id>/test" in rules
|
||||
assert "/api/projects/<int:project_id>/forge" in rules
|
||||
|
||||
|
||||
def test_settings_kv_surface_masks_the_forge_token():
|
||||
def test_settings_kv_surface_masks_the_webhook_secret():
|
||||
"""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)."""
|
||||
forge token; smtp_password had the same exposure). forge_token left the
|
||||
KV with #2778 (keyring rows carry it now), so it is deliberately no
|
||||
longer a secret KEY here."""
|
||||
from scribe.routes.settings import _SECRET_KEYS, _masked
|
||||
|
||||
out = _masked({"forge_token": "tok-123", "smtp_password": "pw", "theme": "dark"})
|
||||
assert out["forge_token"] == "********"
|
||||
out = _masked({"forge_webhook_secret": "sec", "smtp_password": "pw", "theme": "dark"})
|
||||
assert out["forge_webhook_secret"] == "********"
|
||||
assert out["smtp_password"] == "********"
|
||||
assert out["theme"] == "dark"
|
||||
assert {"forge_token", "smtp_password"} <= set(_SECRET_KEYS)
|
||||
assert {"forge_webhook_secret", "smtp_password"} <= set(_SECRET_KEYS)
|
||||
assert "forge_token" not in _SECRET_KEYS
|
||||
# An unset secret stays empty rather than reading as a set-but-masked one.
|
||||
assert _masked({"forge_token": ""})["forge_token"] == ""
|
||||
assert _masked({"forge_webhook_secret": ""})["forge_webhook_secret"] == ""
|
||||
|
||||
|
||||
def test_connection_to_dict_never_carries_the_token():
|
||||
"""The model is the last line: every list/create/update route returns
|
||||
to_dict(), so a field added here is a field leaked there."""
|
||||
from scribe.models.forge_connection import ForgeConnection
|
||||
|
||||
assert "token" not in ForgeConnection.to_dict.__code__.co_consts
|
||||
|
||||
|
||||
def test_config_has_the_docker_secret_channel():
|
||||
@@ -358,14 +376,3 @@ async def test_latest_commit_parses_tolerantly_on_both_adapters():
|
||||
assert await _forge(lambda r: _json(200, [])).latest_commit("a/w", "x.py") == ""
|
||||
|
||||
|
||||
async def test_full_config_builds_a_github_adapter():
|
||||
from scribe.services.forge import GitHubForge
|
||||
|
||||
with _settings({
|
||||
"forge_kind": "github",
|
||||
"forge_base_url": "https://github.com",
|
||||
"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, GitHubForge)
|
||||
|
||||
@@ -57,7 +57,14 @@ def _file_response(content: str, commit_sha: str = SHA) -> httpx.Response:
|
||||
|
||||
|
||||
def _patched(forge):
|
||||
return patch("scribe.services.forge.get_forge", AsyncMock(return_value=forge))
|
||||
"""Stub the owner-keyring lookup (#2778): None → an empty selector, i.e.
|
||||
the owner has no connections — the old 'no forge configured' state."""
|
||||
from scribe.services.forge import ForgeSelector
|
||||
|
||||
selector = ForgeSelector(() if forge is None else (forge,))
|
||||
return patch(
|
||||
"scribe.services.forge.get_forges", AsyncMock(return_value=selector)
|
||||
)
|
||||
|
||||
|
||||
async def test_no_forge_attaches_nothing():
|
||||
@@ -260,7 +267,7 @@ def test_both_pull_surfaces_attach_freshness():
|
||||
|
||||
async def test_forge_failure_inside_lookup_never_breaks_the_pull():
|
||||
with patch(
|
||||
"scribe.services.forge.get_forge", AsyncMock(side_effect=RuntimeError("cfg"))
|
||||
"scribe.services.forge.get_forges", AsyncMock(side_effect=RuntimeError("cfg"))
|
||||
):
|
||||
data = _data()
|
||||
await svc.attach_live_body(_note(), data)
|
||||
|
||||
Reference in New Issue
Block a user