Files
GEWU/tests/api/test_scope_resolution.py
2026-10-14 19:21:35 +08:00

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())