From c0c7222ad12571b98538542d41e651f43b5b9e72 Mon Sep 17 00:00:00 2001 From: taiph9 Date: Mon, 31 Aug 2026 14:46:32 +0900 Subject: [PATCH] refactor(mcp): split target/credential resolver, scope by org_unit:customer:project, support Vietnamese acceptance heading --- .../project_context/providers/issue.py | 151 ++++++++++++----- tests/test_project_context_issue.py | 153 +++++++++++++++--- 2 files changed, 240 insertions(+), 64 deletions(-) diff --git a/mcp_servers/project_context/providers/issue.py b/mcp_servers/project_context/providers/issue.py index c2e551b..6d6fa3c 100644 --- a/mcp_servers/project_context/providers/issue.py +++ b/mcp_servers/project_context/providers/issue.py @@ -9,19 +9,30 @@ read-only Gitea client instead of :class:`UnconfiguredIssueProvider`. Design notes (documented here so a reviewer does not have to guess): -* Credentials (``GITEA_TOKEN``) and the base URL are read INSIDE - ``build_provider``/``_resolve_target`` only — never at import time — because - ``build_provider`` is only ever invoked by +* Target resolution and credential resolution are two SEPARATE, independently + replaceable seams — :class:`GiteaTargetResolver` (identity -> which + repository) and :class:`GiteaTokenResolver` (identity -> which credential). + Today both are simple: one shared read-only service-account token for + every caller, which is an accepted, documented pilot shortcut. The split + exists so that switching to an on-behalf-of-user credential model later + means replacing ONLY :class:`GiteaTokenResolver` — the tool, the target + routing logic, and :func:`build_provider`'s signature never change. + ``GITEA_TOKEN``/``GITEA_BASE_URL`` are read INSIDE these resolvers only — + never at import time — because ``build_provider`` is only ever invoked by ``ProjectProviderResolver.resolve`` (see ``runtime.py``), which ``server.py::dispatch`` calls strictly AFTER ``policy.decide`` returns ``True``. Reading env vars at module scope would read them before the policy check ever runs. -* The provider is bound to exactly one project/repository at construction - time (``identity.project`` -> ``PROJECT_CONTEXT_REPO_MAP``). The - ``project_id`` argument received per call is only used to echo it back in - the response and as a defensive equality check — it is never used to pick - which repository to query. This means a caller can never redirect the - provider to an arbitrary repository by tampering with ``project_id``. +* The provider is bound to exactly one repository at construction time, + resolved from the FULL identity scope — ``org_unit:customer:project``, not + ``project`` alone — via ``PROJECT_CONTEXT_REPO_MAP`` (see + :func:`_scope_key`). Scoping by project alone would let two different + customers/org_units that happen to reuse the same project label collide + onto the same repository mapping. The ``project_id`` argument received per + call is only used to echo it back in the response and as a defensive + equality check — it is never used to pick which repository to query. This + means a caller can never redirect the provider to an arbitrary repository + by tampering with ``project_id``. * ``revision`` pins to the issue's own ``updated_at`` timestamp. Gitea issues (unlike commits/PRs) have no natural commit SHA of their own, so the most meaningful, verifiable "version marker" Gitea offers for an issue is its @@ -97,7 +108,16 @@ _URL_PATTERN = re.compile(r"https?://\S+") _MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]*\]\([^)]*\)") # ATX heading line, e.g. "# Acceptance Criteria" / "## Acceptance Criteria". _HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE) -_ACCEPTANCE_HEADING_NAMES = ("acceptance criteria",) +# English + the Vietnamese phrasing the Core Team's own task template uses +# (see PROJECT_CONTEXT_MCP_TEAM_GUIDE.md-authored issues) — matched +# case-insensitively. Add more synonyms here rather than loosening the +# fallback-to-whole-body behavior, which exists only for issues with no +# fixed template at all. +_ACCEPTANCE_HEADING_NAMES = ( + "acceptance criteria", + "tiêu chí chấp nhận", + "tiêu chí hoàn thành", +) def _extract_heading_section(text: str, heading_names: tuple[str, ...]) -> str | None: @@ -135,13 +155,23 @@ class UnconfiguredIssueProvider: @dataclass(frozen=True) class _GiteaRepoTarget: + """WHICH repository to query. Deliberately carries no credential — see + the module docstring's note on the Target/Credential Resolver split.""" + base_url: str owner: str repo: str - token: str project_id: str +def _scope_key(identity: IdentityContext) -> str: + """The full tenancy scope a repository mapping is keyed by. Using + ``org_unit:customer:project`` (rather than ``project`` alone) means two + different customers/org_units that happen to reuse the same project + label can never collide onto the same Gitea repository.""" + return f"{identity.org_unit}:{identity.customer}:{identity.project}" + + def _load_repo_map() -> dict[str, str]: raw = os.environ.get("PROJECT_CONTEXT_REPO_MAP", "").strip() if not raw: @@ -159,53 +189,86 @@ def _load_repo_map() -> dict[str, str]: ): raise ProviderError( "UNAVAILABLE", - "PROJECT_CONTEXT_REPO_MAP must be a JSON object of project_id -> 'owner/repo'.", + "PROJECT_CONTEXT_REPO_MAP must be a JSON object of " + "'org_unit:customer:project' -> 'owner/repo'.", retryable=False, ) return parsed -def _resolve_target(identity: IdentityContext) -> _GiteaRepoTarget: - """Read-only credential/target resolution. Only ever called AFTER the - runtime's policy has already granted ALLOW for this identity/tool.""" - base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/") - token = os.environ.get("GITEA_TOKEN", "").strip() - if not base_url or not token: - raise ProviderError( - "UNAVAILABLE", - "GITEA_BASE_URL/GITEA_TOKEN are not configured for this environment.", - retryable=False, +class GiteaTargetResolver: + """Resolves WHICH Gitea repository an identity's full scope + (``org_unit``/``customer``/``project``) is approved to read. Pure + routing logic — knows nothing about credentials, so this can evolve + (e.g. a richer tenancy model) without touching how credentials are + obtained. Only ever called AFTER the runtime's policy has already + granted ALLOW for this identity/tool.""" + + def resolve(self, identity: IdentityContext) -> _GiteaRepoTarget: + base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/") + if not base_url: + raise ProviderError( + "UNAVAILABLE", + "GITEA_BASE_URL is not configured for this environment.", + retryable=False, + ) + repo_map = _load_repo_map() + slug = repo_map.get(_scope_key(identity), "") + if not slug or "/" not in slug: + raise ProviderError( + "UNAVAILABLE", + "This org_unit/customer/project scope is not mapped to an " + "approved Gitea repository.", + retryable=False, + ) + owner, _, repo = slug.partition("/") + if not owner or not repo: + raise ProviderError( + "UNAVAILABLE", + "This project's Gitea repository mapping is malformed.", + retryable=False, + ) + return _GiteaRepoTarget( + base_url=base_url, owner=owner, repo=repo, project_id=identity.project, ) - repo_map = _load_repo_map() - slug = repo_map.get(identity.project, "") - if not slug or "/" not in slug: - raise ProviderError( - "UNAVAILABLE", - "This project is not mapped to an approved Gitea repository.", - retryable=False, - ) - owner, _, repo = slug.partition("/") - if not owner or not repo: - raise ProviderError( - "UNAVAILABLE", - "This project's Gitea repository mapping is malformed.", - retryable=False, - ) - return _GiteaRepoTarget( - base_url=base_url, owner=owner, repo=repo, token=token, project_id=identity.project, - ) + + +class GiteaTokenResolver: + """Resolves WHICH credential to use for a given identity. Today: a + single shared read-only service-account token (``GITEA_TOKEN``) for + every caller — an accepted shortcut for a read-only pilot. Isolated + behind this seam so a future on-behalf-of-user credential model only + means replacing THIS class; :class:`GiteaTargetResolver`, + :class:`GiteaIssueProvider`, and :func:`build_provider`'s signature + never change.""" + + def resolve(self, identity: IdentityContext) -> str: + token = os.environ.get("GITEA_TOKEN", "").strip() + if not token: + raise ProviderError( + "UNAVAILABLE", + "GITEA_TOKEN is not configured for this environment.", + retryable=False, + ) + return token def build_provider(identity: IdentityContext) -> IssueProvider: - """Replace only this factory when wiring the approved read-only issue adapter.""" - return GiteaIssueProvider(_resolve_target(identity)) + """Replace only this factory when wiring the approved read-only issue + adapter (or swap in a different :class:`GiteaTokenResolver` alone, to + move to an on-behalf-of-user credential model without touching this + function's signature or callers).""" + target = GiteaTargetResolver().resolve(identity) + token = GiteaTokenResolver().resolve(identity) + return GiteaIssueProvider(target, token) class GiteaIssueProvider: """Read-only adapter mapping one Gitea issue/PR onto the neutral schema.""" - def __init__(self, target: _GiteaRepoTarget) -> None: + def __init__(self, target: _GiteaRepoTarget, token: str) -> None: self._target = target + self._token = token def get_issue_context( self, @@ -345,7 +408,7 @@ class GiteaIssueProvider: f"{self._target.base_url}/api/v1/repos/{self._target.owner}/" f"{self._target.repo}/issues/{issue_key}" ) - headers = {"Authorization": f"token {self._target.token}"} + headers = {"Authorization": f"token {self._token}"} try: response = requests.get(url, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS) except requests.exceptions.Timeout as exc: diff --git a/tests/test_project_context_issue.py b/tests/test_project_context_issue.py index bfc6672..308ec50 100644 --- a/tests/test_project_context_issue.py +++ b/tests/test_project_context_issue.py @@ -20,6 +20,8 @@ from cowork_local.mcp_servers.project_context.foundation import ( ) from cowork_local.mcp_servers.project_context.providers.issue import ( GiteaIssueProvider, + GiteaTargetResolver, + GiteaTokenResolver, UnconfiguredIssueProvider, _GiteaRepoTarget, ) @@ -95,7 +97,6 @@ def _target(**overrides: Any) -> _GiteaRepoTarget: base_url="http://example.test", owner="gitea-admin", repo="cowork-local", - token=FAKE_TOKEN, project_id="cowork-local", ) base.update(overrides) @@ -134,7 +135,7 @@ def test_happy_path_returns_full_schema_with_openable_source( ) -> None: transport = _FakeTransport([_FakeResponse(200, _issue_payload())]) monkeypatch.setattr(requests, "get", transport) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, policy, resolver = _runtime(identity, provider) result = dispatch( @@ -167,7 +168,7 @@ def test_source_fields_are_all_present_and_well_formed( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -215,6 +216,10 @@ def test_denied_project_never_resolves_credentials_or_calls_gitea( assert result.payload["error"]["code"] == "DENIED" assert policy.calls == 1 assert resolver.calls == 0 + # A DENIED response must still carry a correlation_id (traceable/auditable + # by itself) and never a credential of any kind. + assert result.payload["error"]["correlation_id"] + assert FAKE_TOKEN not in str(result.payload) def test_permission_decision_lives_outside_the_tool( @@ -224,7 +229,7 @@ def test_permission_decision_lives_outside_the_tool( outcome, proving `tools/issue_context.py` contains no permission logic of its own.""" monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) arguments = {"project_id": "cowork-local", "issue_key": "1"} allowed_app, _, _ = _runtime(identity, provider, allowed=True) @@ -245,7 +250,7 @@ def test_not_found_issue_maps_to_not_found( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -264,7 +269,7 @@ def test_provider_raises_provider_error_directly_for_not_found( dispatch): the raised exception must carry the right `.code`/`.retryable` for the runtime to map correctly.""" monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) with pytest.raises(ProviderError) as exc_info: provider.get_issue_context( @@ -279,7 +284,7 @@ def test_upstream_timeout_maps_to_upstream_timeout( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(requests, "get", _FakeTransport([requests.exceptions.Timeout("slow")])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -298,7 +303,7 @@ def test_upstream_status_codes_map_to_distinct_error_codes( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, status_code: int, expected_code: str, ) -> None: monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(status_code)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -311,7 +316,7 @@ def test_malformed_gitea_response_maps_to_upstream_error( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, json_body="__missing__")])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -341,7 +346,7 @@ def test_invalid_issue_key_format_rejected_before_network_call( ) -> None: transport = _FakeTransport([]) # empty queue: a real call would raise IndexError monkeypatch.setattr(requests, "get", transport) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -358,7 +363,7 @@ def test_invalid_cursor_rejected_before_network_call( ) -> None: transport = _FakeTransport([]) monkeypatch.setattr(requests, "get", transport) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -372,6 +377,91 @@ def test_invalid_cursor_rejected_before_network_call( assert transport.calls == [] +# --------------------------------------------------------------------------- +# Target Resolver / Credential Resolver split (owner review item #1) +# --------------------------------------------------------------------------- +def test_target_resolver_and_token_resolver_are_independent_seams( + identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Architecture requirement: target routing (which repo) and credential + lookup (which token) must be two independently callable/replaceable + components, not one merged function — so a future on-behalf-of-user + credential model can replace ONLY the token side.""" + monkeypatch.setenv("GITEA_BASE_URL", "http://example.test") + monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN) + monkeypatch.setenv( + "PROJECT_CONTEXT_REPO_MAP", '{"fsg:internal:cowork-local": "gitea-admin/cowork-local"}', + ) + + target = GiteaTargetResolver().resolve(identity) + assert target.owner == "gitea-admin" + assert target.repo == "cowork-local" + assert not hasattr(target, "token") # the target itself carries no credential + + token = GiteaTokenResolver().resolve(identity) + assert token == FAKE_TOKEN + + +def test_repo_mapping_is_scoped_by_full_identity_not_project_alone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two identities that happen to reuse the same `project` label but + belong to different org_unit/customer must resolve to DIFFERENT + repositories, not collide onto the same mapping entry.""" + monkeypatch.setenv("GITEA_BASE_URL", "http://example.test") + monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN) + monkeypatch.setenv( + "PROJECT_CONTEXT_REPO_MAP", + '{"fsg:internal:cowork-local": "gitea-admin/cowork-local", ' + '"other-org:other-customer:cowork-local": "gitea-admin/other-repo"}', + ) + identity_a = IdentityContext( + actor_id="a", org_unit="fsg", customer="internal", + project="cowork-local", granted_scopes=frozenset({"read"}), + ) + identity_b = IdentityContext( + actor_id="b", org_unit="other-org", customer="other-customer", + project="cowork-local", granted_scopes=frozenset({"read"}), + ) + + target_a = GiteaTargetResolver().resolve(identity_a) + target_b = GiteaTargetResolver().resolve(identity_b) + + assert target_a.repo == "cowork-local" + assert target_b.repo == "other-repo" + + +def test_build_provider_wiring_end_to_end_happy_path( + identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Owner review item #2: at least one happy-path test must go through + the REAL `ProjectProviderResolver` -> `PROVIDER_FACTORIES` -> + `build_provider` -> `GiteaTargetResolver`/`GiteaTokenResolver` chain, + instead of injecting a pre-built `GiteaIssueProvider` directly, so the + actual production wiring is proven to work end-to-end.""" + monkeypatch.setenv("GITEA_BASE_URL", "http://example.test") + monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN) + monkeypatch.setenv( + "PROJECT_CONTEXT_REPO_MAP", '{"fsg:internal:cowork-local": "gitea-admin/cowork-local"}', + ) + transport = _FakeTransport([_FakeResponse(200, _issue_payload())]) + monkeypatch.setattr(requests, "get", transport) + policy = RecordingPolicy(allowed=True) + app = ProjectContextRuntime( + identity=identity, policy=policy, credential_resolver=ProjectProviderResolver(), + ) + + result = dispatch( + "get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app, + ) + + assert result.ok is True + assert result.payload["title"] == "MCP pilot" + assert result.payload["correlation_id"] + assert transport.calls[0]["headers"] == {"Authorization": f"token {FAKE_TOKEN}"} + assert transport.calls[0]["url"].endswith("/api/v1/repos/gitea-admin/cowork-local/issues/1") + + # --------------------------------------------------------------------------- # Fail-closed configuration (build_provider itself, via the real resolver) # --------------------------------------------------------------------------- @@ -448,7 +538,7 @@ def test_truncation_and_cursor_paginate_related_items( payload = _issue_payload(body=f"See also {mentions}.") transport = _FakeTransport([_FakeResponse(200, payload), _FakeResponse(200, payload)]) monkeypatch.setattr(requests, "get", transport) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) first = dispatch( @@ -482,7 +572,7 @@ def test_full_detail_uses_a_larger_related_page_than_standard( mentions = " ".join(f"#{n}" for n in range(2, 32)) # 30 distinct related items payload = _issue_payload(body=f"See also {mentions}.") monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -507,7 +597,7 @@ def test_url_fragment_is_not_mistaken_for_a_related_issue( body = "See http://example.test/gitea-admin/cowork-local/wiki/guide#42 and also #7 directly." payload = _issue_payload(body=body) monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -531,7 +621,7 @@ def test_related_excludes_number_that_is_only_a_markdown_link_label( ) payload = _issue_payload(body=body) monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -556,7 +646,7 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done ) payload = _issue_payload(body=body) monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -567,6 +657,29 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done ] +def test_acceptance_criteria_supports_vietnamese_heading( + identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Owner review item #4: Core Team task tickets are increasingly + authored in Vietnamese ('Tiêu chí chấp nhận' / 'Tiêu chí hoàn thành') + — these must be recognized the same way as the English heading, + without falling back and grabbing an unrelated section's checklist.""" + body = ( + "# Tiêu chí chấp nhận\n\n" + "- [ ] Điều kiện thật sự cần đạt.\n\n" + "# Definition of Done\n\n" + "- [ ] Không liên quan, không được lẫn vào.\n" + ) + payload = _issue_payload(body=body) + monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) + app, _, _ = _runtime(identity, provider) + + result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) + + assert result.payload["acceptance_criteria"] == ["Điều kiện thật sự cần đạt."] + + def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading( identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -576,7 +689,7 @@ def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading( body = "Ad-hoc issue, no headings.\n\n- [ ] Just do the thing.\n" payload = _issue_payload(body=body) monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -590,7 +703,7 @@ def test_summary_detail_omits_related_and_shortens_description( long_paragraph = "First paragraph. " * 40 # > 280 chars payload = _issue_payload(body=f"{long_paragraph}\n\nSecond paragraph mentions #2.") monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch( @@ -618,7 +731,7 @@ def test_unexpected_transport_error_does_not_leak_credential_or_raw_exception( f"connect failed for token={FAKE_TOKEN} at internal-host:5432" ) monkeypatch.setattr(requests, "get", _FakeTransport([leaking_exception])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app) @@ -636,7 +749,7 @@ def test_not_found_message_does_not_distinguish_missing_from_inaccessible( """Security requirement: a denial/miss must not reveal whether the underlying resource exists — the safe_message must stay generic.""" monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)])) - provider = GiteaIssueProvider(_target()) + provider = GiteaIssueProvider(_target(), FAKE_TOKEN) app, _, _ = _runtime(identity, provider) result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)