440 lines
20 KiB
Python
440 lines
20 KiB
Python
import asyncio
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx2
|
|
import yaml
|
|
from mcp import Client
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from mcp.types import TextContent
|
|
|
|
from gewu.adapters.git_repository import GitCanonicalRepository
|
|
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
|
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
|
from gewu.application.reading import CanonicalNodeReader
|
|
from gewu.application.search import CanonicalSearchService
|
|
from gewu.bootstrap.app import create_app
|
|
from gewu.domain.contracts import EdgeEndpoint, Scope, SearchRequest, TypedEdge
|
|
from gewu.domain.validation import evidence_digest
|
|
from tests.domain.proposal_fixtures import CLAIM, EVIDENCE, NODE, SOURCE_REVISION
|
|
|
|
|
|
def _canonical_path(prefix: str, stable_id: str) -> str:
|
|
key = hashlib.sha256(stable_id.encode()).hexdigest()
|
|
return f"{prefix}/{key}.yaml"
|
|
|
|
|
|
def test_project_include_resolution_across_http_mcp_and_direct_reads(tmp_path: Path) -> None:
|
|
async def exercise() -> None:
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
|
dome_node = NODE.model_copy(
|
|
update={
|
|
"node_id": "dome:scope-default-node",
|
|
"summary": "scopeprobe dome project record",
|
|
}
|
|
)
|
|
video_source = SOURCE_REVISION.model_copy(
|
|
update={
|
|
"source_id": "video:scope-source",
|
|
"revision_id": "video:scope-source:r1",
|
|
"title": "Video-only scope source",
|
|
"ingested_for": ("interactive-video",),
|
|
"artifact_sha256": None,
|
|
"media_type": "application/octet-stream",
|
|
"byte_length": 0,
|
|
"original_url": "https://video.example/scope-source",
|
|
"archive_status": "reference-only",
|
|
}
|
|
)
|
|
video_evidence = EVIDENCE.model_copy(
|
|
update={"source_revision_id": video_source.revision_id}
|
|
)
|
|
video_assessment = CLAIM.assessment.model_copy(
|
|
update={"evidence_digests": (evidence_digest(video_evidence),)}
|
|
)
|
|
video_claim = CLAIM.model_copy(
|
|
update={"evidence": (video_evidence,), "assessment": video_assessment}
|
|
)
|
|
video_node = NODE.model_copy(
|
|
update={
|
|
"project_id": "interactive-video",
|
|
"node_id": "video:scope-default-node",
|
|
"title": "Video scope fixture",
|
|
"summary": "scopeprobe interactive video project record",
|
|
"claims": (video_claim,),
|
|
}
|
|
)
|
|
edge = TypedEdge(
|
|
edge_id="scope:cross-project-edge",
|
|
edge_type="compared_with",
|
|
source=EdgeEndpoint(kind="node", id=dome_node.node_id),
|
|
target=EdgeEndpoint(kind="node", id=video_node.node_id),
|
|
)
|
|
files = {
|
|
f"projects/{hashlib.sha256(dome_node.project_id.encode()).hexdigest()}/nodes/"
|
|
f"{hashlib.sha256(dome_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
|
dome_node.model_dump(mode="json")
|
|
).encode(),
|
|
f"projects/{hashlib.sha256(video_node.project_id.encode()).hexdigest()}/nodes/"
|
|
f"{hashlib.sha256(video_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
|
video_node.model_dump(mode="json")
|
|
).encode(),
|
|
(
|
|
f"sources/{hashlib.sha256(video_source.source_id.encode()).hexdigest()}/"
|
|
f"{hashlib.sha256(video_source.revision_id.encode()).hexdigest()}.yaml"
|
|
): yaml.safe_dump(video_source.model_dump(mode="json")).encode(),
|
|
_canonical_path("edges", edge.edge_id): yaml.safe_dump(
|
|
edge.model_dump(mode="json")
|
|
).encode(),
|
|
}
|
|
repository.publish_files(
|
|
expected_head=repository.head(),
|
|
files=files,
|
|
message="Add project-scope fixtures",
|
|
index_directory=tmp_path / "publishing",
|
|
)
|
|
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
|
all_scope_token = identities.provision(
|
|
credential_id="all-scope-reader",
|
|
principal_id="all-scope-reader",
|
|
roles=("viewer",),
|
|
project_ids=("dome-performance", "interactive-video", "shared"),
|
|
)
|
|
dome_only_token = identities.provision(
|
|
credential_id="dome-only-reader",
|
|
principal_id="dome-only-reader",
|
|
roles=("viewer",),
|
|
project_ids=("dome-performance", "shared"),
|
|
)
|
|
search_index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
|
indexed_nodes = search_index.canonical_nodes()[1]
|
|
assert {node.node_id for node in indexed_nodes} == {dome_node.node_id, video_node.node_id}
|
|
lexical_scope_results = search_index.search(
|
|
query="scopeprobe",
|
|
project_ids=("dome-performance", "interactive-video"),
|
|
include_shared=False,
|
|
limit=10,
|
|
)
|
|
assert len(lexical_scope_results.nodes) == 2, [
|
|
item.node.summary for item in lexical_scope_results.nodes
|
|
]
|
|
search = CanonicalSearchService(
|
|
index=search_index,
|
|
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
|
canonical_head=repository.head,
|
|
default_includes_provider=lambda project_id: (
|
|
("interactive-video", "shared") if project_id == "dome-performance" else ()
|
|
),
|
|
)
|
|
app = create_app(
|
|
identity_provider=identities,
|
|
node_reader=CanonicalNodeReader(repository),
|
|
search_service=search,
|
|
)
|
|
direct_service_result = search.search(
|
|
SearchRequest(
|
|
query="scopeprobe",
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
|
limit=10,
|
|
budget_tokens=50000,
|
|
),
|
|
principal=identities.authenticate(all_scope_token),
|
|
)
|
|
assert len(direct_service_result.data) == 2
|
|
async with app.router.lifespan_context(app):
|
|
async with httpx2.AsyncClient(
|
|
transport=httpx2.ASGITransport(app=app), base_url="http://localhost"
|
|
) as http:
|
|
default = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert default.status_code == 200, default.text
|
|
assert {item["node"]["project_id"] for item in default.json()["data"]} == {
|
|
"dome-performance",
|
|
"interactive-video",
|
|
}, default.json()
|
|
assert video_source.revision_id in {
|
|
item["source_revision_id"] for item in default.json()["citations"]
|
|
}
|
|
|
|
explicit_empty = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert explicit_empty.status_code == 200, explicit_empty.text
|
|
assert {item["node"]["project_id"] for item in explicit_empty.json()["data"]} == {
|
|
"dome-performance"
|
|
}
|
|
assert video_source.revision_id not in {
|
|
item["source_revision_id"] for item in explicit_empty.json()["citations"]
|
|
}
|
|
|
|
explicit_include = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": ["interactive-video"],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert explicit_include.status_code == 200, explicit_include.text
|
|
assert len(explicit_include.json()["data"]) == 2
|
|
|
|
denied_default = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {dome_only_token}"},
|
|
)
|
|
assert denied_default.status_code == 403
|
|
denied_explicit_include = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": ["interactive-video"],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {dome_only_token}"},
|
|
)
|
|
assert denied_explicit_include.status_code == 403
|
|
allowed_empty = await http.post(
|
|
"/v1/search",
|
|
json={
|
|
"query": "scopeprobe",
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {dome_only_token}"},
|
|
)
|
|
assert allowed_empty.status_code == 200
|
|
|
|
default_neighbors = await http.post(
|
|
"/v1/neighbors",
|
|
json={
|
|
"node_id": dome_node.node_id,
|
|
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert default_neighbors.status_code == 200, default_neighbors.text
|
|
assert default_neighbors.json()["data"][0]["neighbor"]["id"] == video_node.node_id
|
|
restricted_neighbors = await http.post(
|
|
"/v1/neighbors",
|
|
json={
|
|
"node_id": dome_node.node_id,
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert restricted_neighbors.status_code == 200
|
|
assert restricted_neighbors.json()["data"] == []
|
|
|
|
default_sources = await http.post(
|
|
"/v1/sources",
|
|
json={
|
|
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert default_sources.status_code == 200, default_sources.text
|
|
assert video_source.revision_id in {
|
|
item["source"]["revision_id"] for item in default_sources.json()["data"]
|
|
}
|
|
explicit_empty_sources = await http.post(
|
|
"/v1/sources",
|
|
json={
|
|
"scope": {
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
},
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert explicit_empty_sources.status_code == 200
|
|
assert video_source.revision_id not in {
|
|
item["source"]["revision_id"] for item in explicit_empty_sources.json()["data"]
|
|
}
|
|
|
|
direct_node_denied = await http.get(
|
|
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
|
headers={"Authorization": f"Bearer {dome_only_token}"},
|
|
)
|
|
assert direct_node_denied.status_code == 403
|
|
direct_source_denied = await http.get(
|
|
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
|
headers={"Authorization": f"Bearer {dome_only_token}"},
|
|
)
|
|
assert direct_source_denied.status_code == 403
|
|
direct_node_allowed = await http.get(
|
|
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert direct_node_allowed.status_code == 200
|
|
direct_source_allowed = await http.get(
|
|
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
|
headers={"Authorization": f"Bearer {all_scope_token}"},
|
|
)
|
|
assert direct_source_allowed.status_code == 200
|
|
|
|
http.headers["Authorization"] = f"Bearer {all_scope_token}"
|
|
async with Client(
|
|
streamable_http_client("http://localhost/mcp", http_client=http)
|
|
) as mcp:
|
|
mcp_default = await mcp.call_tool(
|
|
"search",
|
|
{
|
|
"query": "scopeprobe",
|
|
"projects": ["dome-performance"],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_default.is_error
|
|
assert isinstance(mcp_default.content[0], TextContent)
|
|
default_payload = json.loads(mcp_default.content[0].text)
|
|
assert len(default_payload["data"]) == 2
|
|
|
|
mcp_default_sources = await mcp.call_tool(
|
|
"list_sources",
|
|
{
|
|
"projects": ["dome-performance"],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_default_sources.is_error
|
|
assert isinstance(mcp_default_sources.content[0], TextContent)
|
|
assert video_source.revision_id in {
|
|
row["source"]["revision_id"]
|
|
for row in json.loads(mcp_default_sources.content[0].text)["data"]
|
|
}
|
|
|
|
mcp_default_neighbors = await mcp.call_tool(
|
|
"neighbors",
|
|
{
|
|
"node_id": dome_node.node_id,
|
|
"projects": ["dome-performance"],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"edge_types": [],
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_default_neighbors.is_error
|
|
assert isinstance(mcp_default_neighbors.content[0], TextContent)
|
|
assert (
|
|
json.loads(mcp_default_neighbors.content[0].text)["data"][0]["neighbor"][
|
|
"id"
|
|
]
|
|
== video_node.node_id
|
|
)
|
|
|
|
mcp_explicit_empty = await mcp.call_tool(
|
|
"search",
|
|
{
|
|
"query": "scopeprobe",
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_explicit_empty.is_error
|
|
assert isinstance(mcp_explicit_empty.content[0], TextContent)
|
|
assert len(json.loads(mcp_explicit_empty.content[0].text)["data"]) == 1
|
|
|
|
mcp_empty_sources = await mcp.call_tool(
|
|
"list_sources",
|
|
{
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_empty_sources.is_error
|
|
assert isinstance(mcp_empty_sources.content[0], TextContent)
|
|
assert video_source.revision_id not in {
|
|
row["source"]["revision_id"]
|
|
for row in json.loads(mcp_empty_sources.content[0].text)["data"]
|
|
}
|
|
|
|
mcp_empty_neighbors = await mcp.call_tool(
|
|
"neighbors",
|
|
{
|
|
"node_id": dome_node.node_id,
|
|
"projects": ["dome-performance"],
|
|
"include_projects": [],
|
|
"include_shared": False,
|
|
"all_projects": False,
|
|
"edge_types": [],
|
|
"limit": 10,
|
|
"budget_tokens": 50000,
|
|
},
|
|
)
|
|
assert not mcp_empty_neighbors.is_error
|
|
assert isinstance(mcp_empty_neighbors.content[0], TextContent)
|
|
assert json.loads(mcp_empty_neighbors.content[0].text)["data"] == []
|
|
|
|
asyncio.run(exercise())
|