update project knowledge function
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""Unit tests for the Jira knowledge provider (search_project_knowledge backend).
|
||||
|
||||
Mirrors the structure of ``test_project_context_knowledge.py`` so the Jira
|
||||
provider is held to the same production contract:
|
||||
- Happy path through real resolver / build_provider wiring
|
||||
- Cross-project isolation (structural, not filter-based)
|
||||
- DENIED before provider when policy rejects
|
||||
- Untrusted content fence inherited
|
||||
- Empty results are valid
|
||||
- Pagination / cursor support
|
||||
- Output bounds respected
|
||||
- Malformed upstream handled gracefully
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.jira_knowledge.index_repository import JiraKnowledgeIndex
|
||||
from cowork_local.domain.jira_knowledge.canonical_issue import CanonicalJiraIssue, JiraProvenance
|
||||
from cowork_local.mcp_servers.project_context.foundation import (
|
||||
IdentityContext,
|
||||
ProjectContextRuntime,
|
||||
ProviderError,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.providers.jira_knowledge import (
|
||||
JiraKnowledgeProvider,
|
||||
UnconfiguredJiraKnowledgeProvider,
|
||||
_JiraKbTarget,
|
||||
build_provider,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.server import dispatch
|
||||
|
||||
|
||||
PROJECT = "proj-alpha"
|
||||
OTHER_PROJECT = "proj-beta"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures / test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class RecordingPolicy:
|
||||
allowed: bool
|
||||
calls: int = 0
|
||||
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
||||
self.calls += 1
|
||||
return self.allowed
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeJiraTargetResolver:
|
||||
"""Returns a fixed target or raises UNAVAILABLE."""
|
||||
target: _JiraKbTarget | None = None
|
||||
|
||||
def resolve(self, identity: IdentityContext) -> _JiraKbTarget:
|
||||
if self.target is None:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"No Jira binding for test.",
|
||||
retryable=False,
|
||||
)
|
||||
return self.target
|
||||
|
||||
|
||||
def identity_for(project: str) -> IdentityContext:
|
||||
return IdentityContext(
|
||||
actor_id="test-agent",
|
||||
org_unit="eng",
|
||||
customer="internal",
|
||||
project=project,
|
||||
granted_scopes=frozenset({"read"}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identity() -> IdentityContext:
|
||||
return identity_for(PROJECT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_root(tmp_path: Path) -> Path:
|
||||
return tmp_path / "jira_kb"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_index(index_root: Path) -> JiraKnowledgeIndex:
|
||||
"""Index with two projects, each containing a unique secret marker."""
|
||||
idx = JiraKnowledgeIndex(index_root=index_root)
|
||||
|
||||
alpha_issue = CanonicalJiraIssue(
|
||||
knowledge_id="ALPHA/ALPHA-1",
|
||||
project_id=PROJECT,
|
||||
title="Account lock policy",
|
||||
content="The account lock engages after five failed login attempts. The alpha marker is secret-alpha.",
|
||||
metadata={"issue_type": "Story", "status": "Open"},
|
||||
provenance=JiraProvenance(
|
||||
system="jira",
|
||||
issue_key="ALPHA-1",
|
||||
project_key="ALPHA",
|
||||
source_url="https://jira.test/browse/ALPHA-1",
|
||||
source_updated="2025-06-01T10:00:00.000+0000",
|
||||
issue_type="Story",
|
||||
status="Open",
|
||||
),
|
||||
ingested_at="2025-06-01T12:00:00+00:00",
|
||||
)
|
||||
idx.upsert(alpha_issue)
|
||||
|
||||
beta_issue = CanonicalJiraIssue(
|
||||
knowledge_id="BETA/BETA-1",
|
||||
project_id=OTHER_PROJECT,
|
||||
title="Beta customer design",
|
||||
content="The beta marker is secret-beta and must never reach another project.",
|
||||
metadata={"issue_type": "Story", "status": "Open"},
|
||||
provenance=JiraProvenance(
|
||||
system="jira",
|
||||
issue_key="BETA-1",
|
||||
project_key="BETA",
|
||||
source_url="https://jira.test/browse/BETA-1",
|
||||
source_updated="2025-06-01T10:00:00.000+0000",
|
||||
issue_type="Story",
|
||||
status="Open",
|
||||
),
|
||||
ingested_at="2025-06-01T12:00:00+00:00",
|
||||
)
|
||||
idx.upsert(beta_issue)
|
||||
return idx
|
||||
|
||||
|
||||
def _make_provider(target: _JiraKbTarget, index: JiraKnowledgeIndex) -> JiraKnowledgeProvider:
|
||||
return JiraKnowledgeProvider(target, index=index)
|
||||
|
||||
|
||||
def _search(provider: JiraKnowledgeProvider, **kwargs: Any) -> dict[str, Any]:
|
||||
defaults = {
|
||||
"project_id": PROJECT,
|
||||
"query": "account lock",
|
||||
"detail": "standard",
|
||||
"top_k": 5,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return provider.search_knowledge(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestHappyPath:
|
||||
def test_returns_ranked_results_with_source_evidence(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
result = _search(provider, query="account lock after failed login")
|
||||
|
||||
assert result["returned"] >= 1
|
||||
item = result["items"][0]
|
||||
assert item["source"]["system"] == "jira"
|
||||
assert "ALPHA-1" in item["source"]["url"]
|
||||
assert item["score"] > 0
|
||||
assert "account lock" in item["excerpt"].lower()
|
||||
|
||||
def test_empty_query_returns_no_results(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
result = _search(provider, query="xyznonexistent")
|
||||
|
||||
assert result["returned"] == 0
|
||||
assert result["items"] == ()
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestProjectIsolation:
|
||||
def test_cross_project_secret_not_leaked(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
"""Identity for proj-alpha searching 'secret-beta' must find ZERO results."""
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
result = _search(provider, query="secret-beta")
|
||||
|
||||
items_json = json.dumps(result.get("items", []))
|
||||
assert "secret-beta" not in items_json
|
||||
assert result["returned"] == 0
|
||||
|
||||
def test_project_scope_mismatch_raises(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
"""Even if policy allowed it, mismatched project_id is rejected."""
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
with pytest.raises(ProviderError, match="scope mismatch"):
|
||||
_search(provider, project_id=OTHER_PROJECT, query="anything")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unconfigured provider
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUnconfigured:
|
||||
def test_unconfigured_raises_unavailable(self) -> None:
|
||||
provider = UnconfiguredJiraKnowledgeProvider()
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
provider.search_knowledge(project_id="x", query="y")
|
||||
assert exc_info.value.code == "UNAVAILABLE"
|
||||
assert not exc_info.value.retryable
|
||||
|
||||
def test_build_provider_returns_unconfigured_when_no_binding(
|
||||
self, identity: IdentityContext,
|
||||
) -> None:
|
||||
provider = build_provider(identity, target_resolver=FakeJiraTargetResolver(target=None))
|
||||
assert isinstance(provider, UnconfiguredJiraKnowledgeProvider)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pagination
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPagination:
|
||||
def test_cursor_pagination(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
page1 = _search(provider, query="account", top_k=1)
|
||||
assert page1["returned"] == 1
|
||||
|
||||
if page1["next_cursor"]:
|
||||
page2 = _search(provider, query="account", top_k=1, cursor=page1["next_cursor"])
|
||||
assert page2["returned"] >= 0 # may be 0 if only one match
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output bounds
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestOutputBounds:
|
||||
def test_top_k_respected(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
result = _search(provider, query="account", top_k=1)
|
||||
assert result["returned"] <= 1
|
||||
|
||||
def test_detail_levels_control_excerpt_length(
|
||||
self, populated_index: JiraKnowledgeIndex,
|
||||
) -> None:
|
||||
target = _JiraKbTarget(cowork_project_id=PROJECT, jira_project_key="ALPHA")
|
||||
provider = _make_provider(target, populated_index)
|
||||
|
||||
summary = _search(provider, query="account", detail="summary", top_k=1)
|
||||
full = _search(provider, query="account", detail="full", top_k=1)
|
||||
|
||||
if summary["returned"] > 0 and full["returned"] > 0:
|
||||
assert len(summary["items"][0]["excerpt"]) <= len(full["items"][0]["excerpt"])
|
||||
Reference in New Issue
Block a user