Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:21 +02:00
commit 7ca726caf2
659 files changed
+98171

No files matched your search

View File
Whitespace-only changes.
@@ -0,0 +1,482 @@
from __future__ import annotations
import asyncio
import json
import re
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
import pytest
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
from ragcore.api.v1.answers.service import (
AnswerEventType,
AnswerQuery,
AnswerService,
)
from ragcore.api.v1.context.service import ContextExecutionRequest, ContextExecutionResult
from ragcore.api.v1.search.service import SearchQuery
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Workspace
from ragcore.main import create_app
from ragcore.retrieval.context import ContextCandidate
WORKSPACE_ID = UUID("00000000-0000-0000-0000-000000000001")
SPACE_ID = UUID("00000000-0000-0000-0000-000000000002")
EVIDENCE_EXCERPT = (
"The quarterly maintenance window for the payment gateway runs every "
"second Sunday from midnight to four in the morning Central European time."
)
class FixtureContextExecutor:
def __init__(
self,
candidate: ContextCandidate | None,
*,
degraded: bool = False,
degradation_codes: tuple[str, ...] = (),
) -> None:
self.candidate = candidate
self.degraded = degraded
self.degradation_codes = degradation_codes
self.calls: list[ContextExecutionRequest] = []
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
self.calls.append(request)
if self.candidate is None:
return ContextExecutionResult(
degraded=self.degraded, degradation_codes=self.degradation_codes
)
citation = self.candidate.citation
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(
candidates=(replace(self.candidate, citation=refreshed),),
degraded=self.degraded,
degradation_codes=self.degradation_codes,
)
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
class FixtureGenerator:
"""``None`` in ``responses`` means: build a valid answer citing whichever
citation id the prompt's evidence fence actually carries -- the real id
depends on the request's own (unpredictable) retrieval_run_id, so it
cannot be precomputed by the test itself.
"""
def __init__(self, responses: list[str | None] | None = None) -> None:
self.responses: list[str | None] = list(responses if responses is not None else [None])
self.calls: list[tuple[str, ...]] = []
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
self.calls.append(tuple(space.slug for space in spaces))
if not self.responses:
raise AssertionError("generator was called more times than expected")
response = self.responses.pop(0)
if response is not None:
return response
match = _CITATION_ID.search(prompt)
assert match is not None, "prompt did not contain a citation id fence"
return json.dumps(
{
"status": "answerable",
"answer": (
"The quarterly maintenance window for the payment gateway runs "
"every second Sunday from midnight to four in the morning."
),
"citations": [match.group(1)],
}
)
class HangingGenerator:
def __init__(self) -> None:
self.started = asyncio.Event()
self.cancelled = False
async def generate(self, *, prompt: str, generation_profile: str, spaces) -> str:
self.started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
self.cancelled = True
raise
return "unreachable"
class FixtureInputs:
def __init__(self, workspace: Workspace, principal: Principal, space_id: UUID) -> None:
self.workspace = workspace
self.principal = principal
self.space_id = space_id
async def load(self, principal: Principal):
space = self.workspace.create_knowledge_space(
id=self.space_id, name="Answers space", slug="answers-space"
)
return type(
"Inputs",
(),
{
"spaces": (space,),
"grants": (),
"user_grants": (
self.workspace.grant_user(
id=uuid4(),
user_id=principal.id,
knowledge_space=space,
role=Role.EDITOR,
),
),
"application": Application(
id=uuid4(),
workspace_id=self.workspace.id,
name="Answers application",
default_knowledge_space_ids=frozenset({self.space_id}),
),
},
)()
def _candidate(excerpt: str = EVIDENCE_EXCERPT) -> ContextCandidate:
citation = Citation.create(
workspace_id=WORKSPACE_ID,
space_id=SPACE_ID,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt=excerpt,
source_uri="https://example.test/doc.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
return ContextCandidate(citation, excerpt, 1, 1.0)
def _fixture(
*,
candidate: ContextCandidate | None,
generator: object,
degraded: bool = False,
degradation_codes: tuple[str, ...] = (),
) -> tuple[object, Principal, UUID, FixtureContextExecutor]:
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
space_id = SPACE_ID
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
executor = FixtureContextExecutor(
candidate, degraded=degraded, degradation_codes=degradation_codes
)
service = AnswerService(
authorization=RetrievalAuthorizationService(),
inputs=FixtureInputs(workspace, principal, space_id),
context_executor=executor,
generator=generator,
)
api = create_app()
api.dependency_overrides[get_answer_principal] = lambda: principal
api.dependency_overrides[get_answer_application] = lambda: service
return api, principal, space_id, executor
def test_openapi_and_runtime_expose_answer_operation() -> None:
candidate = _candidate()
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
assert set(api.openapi()["paths"]["/v1/answers"]) == {"post"}
assert "post" in document["paths"]["/v1/answers"]
def test_grounded_answer_returns_citations_and_claims() -> None:
candidate = _candidate()
generator = FixtureGenerator()
api, _, space_id, executor = _fixture(candidate=candidate, generator=generator)
response = TestClient(api).post(
"/v1/answers",
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200, response.json()
body = response.json()
# A single contributing document assembles as "partially_answerable"
# (ContextAssembler's own document-diversity heuristic); generation is
# still expected to proceed for any status other than not_answerable.
assert body["answerability"] == "partially_answerable"
assert "maintenance window" in body["answer"]
assert body["citations"]
assert body["claims"]
assert all(claim["citation_ids"] for claim in body["claims"])
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
def test_reranker_failure_degrades_but_still_returns_a_grounded_answer() -> None:
"""M8-01: a reranker that 404s or times out must not turn found evidence
into a not_answerable, 0-citation response -- it degrades to the fused
ranking, and the caller can see that happened via degraded=true."""
candidate = _candidate()
generator = FixtureGenerator()
api, _, space_id, _ = _fixture(
candidate=candidate,
generator=generator,
degraded=True,
degradation_codes=("rerank_rejected",),
)
response = TestClient(api).post(
"/v1/answers",
json={"query": "when is the maintenance window?", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200, response.json()
body = response.json()
assert body["answerability"] != "not_answerable"
assert body["citations"]
assert body["degraded"] is True
assert "rerank_rejected" in body["degradation_reasons"]
def test_search_authorization_is_reused_and_narrows_to_effective_spaces() -> None:
candidate = _candidate()
generator = FixtureGenerator()
api, _, _, executor = _fixture(candidate=candidate, generator=generator)
forbidden_space = uuid4()
response = TestClient(api).post(
"/v1/answers",
json={"query": "anything", "requested_space_ids": [str(forbidden_space)]},
)
assert response.status_code == 200
body = response.json()
assert body["answerability"] == "not_answerable"
assert body["answer"] == ""
assert executor.calls == []
assert generator.calls == []
def test_not_answerable_context_never_calls_the_generator() -> None:
generator = FixtureGenerator()
api, _, space_id, executor = _fixture(candidate=None, generator=generator)
response = TestClient(api).post(
"/v1/answers",
json={"query": "anything", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200
body = response.json()
assert body["answerability"] == "not_answerable"
assert body["answer"] == ""
assert body["claims"] == []
assert len(executor.calls) == 1
assert generator.calls == []
def test_invalid_generated_output_is_rejected_not_silently_accepted() -> None:
candidate = _candidate()
generator = FixtureGenerator(["not json at all", "still not json"])
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
response = TestClient(api).post(
"/v1/answers",
json={
"query": "when is the maintenance window?",
"requested_space_ids": [str(space_id)],
},
)
assert response.status_code == 503
# ClaimCitationValidator's own retry loop exhausted both attempts and
# raises its stable "exhausted" code, not a silently-degraded answer.
assert response.json()["code"] == "VALIDATION_RETRIES_EXHAUSTED"
assert len(generator.calls) == 2
def test_debug_output_requires_admin_capability() -> None:
candidate = _candidate()
api, _, space_id, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
response = TestClient(api).post(
"/v1/answers",
json={
"query": "anything",
"requested_space_ids": [str(space_id)],
"include_debug": True,
},
)
assert response.status_code == 403
assert response.json()["code"] == "DEBUG_NOT_ALLOWED"
def test_missing_answer_service_is_503_problem_details() -> None:
candidate = _candidate()
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
# create_app() always wires a real answer_application now; popping the
# dependency override alone would just fall through to that real service
# instead of exercising get_answer_application's own missing-state
# fallback, so the state attribute itself has to be genuinely absent.
api.dependency_overrides.pop(get_answer_application)
del api.state.answer_application
response = TestClient(api).post("/v1/answers", json={"query": "anything"})
assert response.status_code == 503
assert response.json()["code"] == "ANSWERS_UNAVAILABLE"
def test_missing_query_is_a_validation_error() -> None:
candidate = _candidate()
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
response = TestClient(api).post("/v1/answers", json={})
assert response.status_code == 422
assert response.json()["code"] == "VALIDATION_ERROR"
def test_sse_stream_emits_metadata_before_done_in_order() -> None:
candidate = _candidate()
generator = FixtureGenerator()
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
with TestClient(api).stream(
"POST",
"/v1/answers",
json={
"query": "when is the maintenance window?",
"requested_space_ids": [str(space_id)],
"stream": True,
},
) as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
raw = response.read().decode()
frames = [frame for frame in raw.strip().split("\n\n") if frame]
assert len(frames) == 2
def _parse(frame: str) -> tuple[str, dict]:
lines = frame.splitlines()
event_line = next(line for line in lines if line.startswith("event: "))
data_line = next(line for line in lines if line.startswith("data: "))
return event_line[len("event: ") :], json.loads(data_line[len("data: ") :])
first_type, first_data = _parse(frames[0])
second_type, second_data = _parse(frames[1])
assert first_type == AnswerEventType.RETRIEVAL_METADATA.value
assert first_data["answerability"] == "partially_answerable"
assert "effective_space_ids" in first_data
assert second_type == AnswerEventType.DONE.value
assert second_data["answerability"] == "partially_answerable"
assert second_data["citations"]
assert "maintenance window" in second_data["answer"]
def test_sse_stream_reports_a_terminal_error_event_on_invalid_output() -> None:
candidate = _candidate()
generator = FixtureGenerator(["not json", "still not json"])
api, _, space_id, _ = _fixture(candidate=candidate, generator=generator)
with TestClient(api).stream(
"POST",
"/v1/answers",
json={
"query": "when is the maintenance window?",
"requested_space_ids": [str(space_id)],
"stream": True,
},
) as response:
assert response.status_code == 200
raw = response.read().decode()
frames = [frame for frame in raw.strip().split("\n\n") if frame]
assert len(frames) == 2
assert "event: retrieval_metadata" in frames[0]
assert "event: error" in frames[1]
assert '"validation_retries_exhausted"' in frames[1]
async def test_disconnect_cancels_provider_work() -> None:
candidate = _candidate()
generator = HangingGenerator()
workspace = Workspace(WORKSPACE_ID, "Answers workspace", "answers-workspace")
space_id = SPACE_ID
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
executor = FixtureContextExecutor(candidate)
service = AnswerService(
authorization=RetrievalAuthorizationService(),
inputs=FixtureInputs(workspace, principal, space_id),
context_executor=executor,
generator=generator,
)
query = AnswerQuery(
query=SearchQuery(
query="when is the maintenance window?", requested_space_ids=(space_id,)
)
)
authorized = await service.authorize(principal, query)
stream = service.answer_stream(principal, query, authorized)
first_event = await stream.__anext__()
assert first_event.type is AnswerEventType.RETRIEVAL_METADATA
with pytest.raises(TimeoutError):
await asyncio.wait_for(stream.__anext__(), timeout=0.2)
assert generator.cancelled is True
def test_answer_response_schema_matches_runtime_openapi() -> None:
candidate = _candidate()
api, _, _, _ = _fixture(candidate=candidate, generator=FixtureGenerator())
static = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)["components"]["schemas"]
runtime = api.openapi()["components"]["schemas"]
assert static["AnswerClaim"]["required"] == runtime["AnswerClaim"]["required"]
assert static["AnswerResponse"]["required"] == runtime["AnswerResponse"]["required"]
def _resolved_required(schemas: dict, ref: dict) -> set[str]:
name = ref["$ref"].rsplit("/", 1)[-1]
return set(schemas[name].get("required", ()))
static_citation_ref = static["AnswerResponse"]["properties"]["citations"]["items"]
runtime_citation_ref = runtime["AnswerResponse"]["properties"]["citations"]["items"]
assert _resolved_required(static, static_citation_ref) == _resolved_required(
runtime, runtime_citation_ref
)
@@ -0,0 +1,253 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
import pytest
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal
from ragcore.api.v1.context.service import (
ContextExecutionRequest,
ContextExecutionResult,
ContextService,
)
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Workspace
from ragcore.main import create_app
from ragcore.retrieval.context import ContextCandidate
class FixtureInputs:
def __init__(self, workspace: Workspace, space_id: UUID, principal: Principal) -> None:
self.workspace = workspace
self.space_id = space_id
self.principal = principal
async def load(self, principal: Principal):
space = self.workspace.create_knowledge_space(
id=self.space_id,
name="Context space",
slug="context-space",
)
return type(
"Inputs",
(),
{
"spaces": (space,),
"grants": (),
"user_grants": (
self.workspace.grant_user(
id=uuid4(),
user_id=principal.id,
knowledge_space=space,
role=Role.EDITOR,
),
),
"application": Application(
id=uuid4(),
workspace_id=self.workspace.id,
name="Context application",
default_knowledge_space_ids=frozenset({self.space_id}),
),
},
)()
class FixtureExecutor:
def __init__(self, candidate: ContextCandidate) -> None:
self.candidate = candidate
self.calls: list[ContextExecutionRequest] = []
self.fail = False
self.degraded = False
self.degradation_codes: tuple[str, ...] = ()
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
self.calls.append(request)
if self.fail:
raise RuntimeError("provider secret must not leak")
citation = self.candidate.citation
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(
candidates=(replace(self.candidate, citation=refreshed),),
degraded=self.degraded,
degradation_codes=self.degradation_codes,
)
def _fixture() -> tuple[object, Principal, UUID, ContextCandidate, FixtureExecutor]:
workspace = Workspace(uuid4(), "Context workspace", "context-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt="Context evidence",
source_uri="https://example.test/context.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = ContextCandidate(citation, "Context evidence", 1, 1.0)
executor = FixtureExecutor(candidate)
service = ContextService(
authorization=RetrievalAuthorizationService(),
inputs=FixtureInputs(workspace, space_id, principal),
executor=executor,
)
api = create_app()
api.dependency_overrides[get_context_principal] = lambda: principal
api.dependency_overrides[get_context_application] = lambda: service
return api, principal, space_id, candidate, executor
def test_openapi_and_runtime_expose_context_operation() -> None:
api, _, _, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
assert set(api.openapi()["paths"]["/v1/context"]) == {"post"}
assert "post" in document["paths"]["/v1/context"]
def test_context_preserves_citations_and_budget() -> None:
api, _, space_id, _, executor = _fixture()
response = TestClient(api).post(
"/v1/context",
json={
"query": "context",
"requested_space_ids": [str(space_id)],
"max_context_tokens": 256,
},
)
assert response.status_code == 200, response.json()
body = response.json()
assert body["blocks"][0]["citation_ids"] == [body["citations"][0]["id"]]
assert body["citations"][0]["excerpt"] in body["blocks"][0]["text"]
assert body["token_count"] <= 256
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
def test_context_surfaces_degraded_retrieval() -> None:
"""M8-01: a caller must be able to see that context was assembled from a
degraded retrieval (e.g. reranker fallback), not just that citations
happen to still be there."""
api, _, space_id, _, executor = _fixture()
executor.degraded = True
executor.degradation_codes = ("rerank_rejected",)
response = TestClient(api).post(
"/v1/context",
json={"query": "context", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200, response.json()
body = response.json()
assert body["degraded"] is True
assert "rerank_rejected" in body["degradation_reasons"]
assert body["citations"]
def test_context_cannot_expand_to_an_unauthorized_requested_space() -> None:
api, _, _, _, executor = _fixture()
response = TestClient(api).post(
"/v1/context",
json={"query": "context", "requested_space_ids": [str(uuid4())]},
)
assert response.status_code == 200
assert response.json()["blocks"] == []
assert executor.calls == []
def test_context_validation_errors_are_problem_details() -> None:
api, _, _, _, _ = _fixture()
response = TestClient(api).post(
"/v1/context",
json={"query": " ", "max_context_tokens": 256},
)
assert response.status_code == 422
assert response.json()["code"] == "VALIDATION_ERROR"
@pytest.mark.parametrize("field", ["query"])
def test_missing_required_context_values_are_rejected(field: str) -> None:
api, _, _, _, _ = _fixture()
payload = {"query": "context", "max_context_tokens": 256}
payload.pop(field)
response = TestClient(api).post("/v1/context", json=payload)
assert response.status_code == 422
def test_unexpected_executor_failure_is_redacted_and_stable() -> None:
api, _, space_id, _, executor = _fixture()
executor.fail = True
response = TestClient(api).post(
"/v1/context",
json={"query": "context", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 503
body = response.json()
assert body["code"] == "CONTEXT_UNAVAILABLE"
assert "provider secret" not in response.text
def test_context_response_schema_matches_runtime_openapi() -> None:
api, _, _, _, _ = _fixture()
static = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)["components"]["schemas"]
runtime = api.openapi()["components"]["schemas"]
assert set(static["ContextRequest"]["properties"]) == set(
runtime["ContextRequest"]["properties"]
)
assert static["ContextRequest"]["additionalProperties"] is False
max_context_tokens = static["ContextRequest"]["properties"]["max_context_tokens"]
assert {key: max_context_tokens[key] for key in ("type", "minimum", "maximum", "default")} == {
"type": "integer",
"minimum": 256,
"maximum": 64000,
"default": 8000,
}
assert static["ContextBlockResponse"]["required"] == runtime["ContextBlockResponse"]["required"]
assert static["ContextBlockResponse"]["additionalProperties"] is False
assert static["ContextResponse"]["required"] == runtime["ContextResponse"]["required"]
assert static["ContextResponse"]["additionalProperties"] is False
assert static["ContextResponse"]["properties"]["blocks"]["items"] == {
"$ref": "#/components/schemas/ContextBlockResponse"
}
assert static["ContextResponse"]["properties"]["citations"]["items"] == {
"$ref": "#/components/schemas/CitationResponse"
}
assert static["CitationResponse"]["required"] == runtime["CitationResponse"]["required"]
assert static["CitationResponse"]["additionalProperties"] is False
@@ -0,0 +1,278 @@
from __future__ import annotations
from pathlib import Path
from uuid import UUID, uuid4
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.control.dependencies import (
get_control_application,
get_credential_service,
get_principal,
)
from ragcore.application.authorization import AuthorizationService
from ragcore.application.control.api import ControlPlaneApplication
from ragcore.application.identity.credentials import ServiceAccountCredentialService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.control import (
Application,
Grant,
KnowledgeSpace,
Policy,
Role,
ServiceAccount,
UserGrant,
Workspace,
)
from ragcore.infrastructure.database.identity.memory import InMemoryCredentialRepository
from ragcore.main import create_app
class FakeControlRepository:
def __init__(self, workspace: Workspace) -> None:
self.workspace = workspace
self.applications: list[Application] = []
self.service_accounts: list[ServiceAccount] = []
self.spaces: list[KnowledgeSpace] = []
self.grants: list[Grant] = []
self.policies: list[Policy] = []
async def get_workspace(self, workspace_id: UUID) -> Workspace | None:
return self.workspace if workspace_id == self.workspace.id else None
async def create_application(self, entity: Application) -> Application:
self.applications.append(entity)
return entity
async def get_application(self, application_id: UUID, workspace_id: UUID) -> Application | None:
return next(
(
item
for item in self.applications
if item.id == application_id and item.workspace_id == workspace_id
),
None,
)
async def list_applications(self, workspace_id: UUID) -> tuple[Application, ...]:
return tuple(item for item in self.applications if item.workspace_id == workspace_id)
async def create_service_account(self, entity: ServiceAccount) -> ServiceAccount:
self.service_accounts.append(entity)
return entity
async def get_service_account(
self, service_account_id: UUID, workspace_id: UUID
) -> ServiceAccount | None:
return next(
(
item
for item in self.service_accounts
if item.id == service_account_id and item.workspace_id == workspace_id
),
None,
)
async def get_service_account_by_id(self, service_account_id: UUID) -> ServiceAccount | None:
return next((item for item in self.service_accounts if item.id == service_account_id), None)
async def create_space(self, entity: KnowledgeSpace) -> KnowledgeSpace:
self.spaces.append(entity)
return entity
async def list_spaces(self, workspace_id: UUID) -> tuple[KnowledgeSpace, ...]:
return tuple(item for item in self.spaces if item.workspace_id == workspace_id)
async def create_grant(self, entity: Grant) -> Grant:
self.grants.append(entity)
return entity
async def create_user_grant(self, entity: UserGrant) -> UserGrant:
self.grants.append(entity)
return entity
async def create_policy(self, entity: Policy) -> Policy:
self.policies.append(entity)
return entity
async def list_policies(self, workspace_id: UUID) -> tuple[Policy, ...]:
return tuple(item for item in self.policies if item.workspace_id == workspace_id)
def _fixture():
workspace = Workspace(uuid4(), "Contract workspace", "contract-workspace")
principal = Principal(
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
)
repository = FakeControlRepository(workspace)
service = ControlPlaneApplication(repository, AuthorizationService())
credential_repository = InMemoryCredentialRepository()
credential_service = ServiceAccountCredentialService(
credential_repository,
"x" * 32,
repository.get_service_account_by_id,
)
app = create_app()
app.dependency_overrides[get_principal] = lambda: principal
app.dependency_overrides[get_control_application] = lambda: service
app.dependency_overrides[get_credential_service] = lambda: credential_service
return app, repository, principal
def test_openapi_and_runtime_expose_same_control_operations() -> None:
app, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
documented = {
path: set(operations)
for path, operations in document["paths"].items()
if path.startswith("/v1/") and "control" in str(document["paths"][path])
}
runtime = {
path: set(operations)
for path, operations in app.openapi()["paths"].items()
if path.startswith("/v1/") and "control" in str(operations)
}
assert set(runtime) == set(documented)
assert all(
{method.lower() for method in methods} == documented[path]
for path, methods in runtime.items()
)
def test_missing_authentication_is_problem_details() -> None:
app, _, _ = _fixture()
app.dependency_overrides.pop(get_principal)
response = TestClient(app).get("/v1/applications")
assert response.status_code == 401
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
def test_missing_idempotency_key_is_problem_details() -> None:
app, _, _ = _fixture()
response = TestClient(app).post(
"/v1/applications",
json={"slug": "missing-key", "display_name": "Missing key"},
)
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "VALIDATION_ERROR"
def test_pagination_and_invalid_cursor_error_code() -> None:
app, repository, principal = _fixture()
client = TestClient(app)
headers = {"Idempotency-Key": "pagination-key-0001"}
for slug in ("first-app", "second-app"):
response = client.post(
"/v1/applications",
json={"slug": slug, "display_name": slug, "scopes": ["search"]},
headers=headers | {"Idempotency-Key": f"{slug}-idempotency"},
)
assert response.status_code == 201
first = client.get("/v1/applications?limit=1")
assert first.status_code == 200
assert len(first.json()["items"]) == 1
assert first.json()["next_cursor"] == "1"
second = client.get(f"/v1/applications?limit=1&cursor={first.json()['next_cursor']}")
assert second.status_code == 200
assert len(second.json()["items"]) == 1
invalid = client.get("/v1/applications?cursor=not-a-cursor")
assert invalid.status_code == 400
assert invalid.json()["code"] == "INVALID_CURSOR"
assert len(repository.applications) == 2
assert principal.workspace_id == repository.workspace.id
def test_idempotency_replay_and_conflict_do_not_duplicate() -> None:
app, repository, _ = _fixture()
client = TestClient(app)
headers = {"Idempotency-Key": "stable-create-key-01"}
payload = {"slug": "stable-app", "display_name": "Stable app", "scopes": ["search"]}
first = client.post("/v1/applications", json=payload, headers=headers)
replay = client.post("/v1/applications", json=payload, headers=headers)
conflict = client.post(
"/v1/applications",
json={"slug": "other-app", "display_name": "Other app"},
headers=headers,
)
assert first.status_code == 201
assert replay.status_code == 200
assert replay.json()["id"] == first.json()["id"]
assert conflict.status_code == 409
assert conflict.json()["code"] == "IDEMPOTENCY_KEY_REUSED"
assert len(repository.applications) == 1
def test_operation_policy_and_credentials_are_enforced() -> None:
app, repository, principal = _fixture()
client = TestClient(app)
create = client.post(
"/v1/applications",
json={"slug": "credential-app", "display_name": "Credential app", "scopes": ["search"]},
headers={"Idempotency-Key": "application-create-key"},
)
assert create.status_code == 201
application_id = create.json()["id"]
credential = client.post(
f"/v1/applications/{application_id}/credentials",
json={"name": "runtime", "scopes": ["search"]},
headers={"Idempotency-Key": "credential-create-key"},
)
assert credential.status_code == 201
assert credential.json()["token"].startswith("rc_sa_")
assert len(repository.service_accounts) == 1
replay = client.post(
f"/v1/applications/{application_id}/credentials",
json={"name": "runtime", "scopes": ["search"]},
headers={"Idempotency-Key": "credential-create-key"},
)
assert replay.status_code == 200
assert replay.json()["id"] == credential.json()["id"]
policy = client.post(
"/v1/policies",
json={"operation": "answer", "required_scopes": ["search"]},
headers={"Idempotency-Key": "policy-create-key"},
)
assert policy.status_code == 201
assert client.get("/v1/policies").status_code == 200
assert principal.roles == frozenset({Role.WORKSPACE_ADMIN})
def test_non_admin_cannot_manage_spaces() -> None:
app, _, principal = _fixture()
denied = Principal(
principal.id, principal.workspace_id, PrincipalType.HUMAN, frozenset({Role.VIEWER})
)
app.dependency_overrides[get_principal] = lambda: denied
response = TestClient(app).post(
"/v1/knowledge-spaces",
json={"slug": "private", "display_name": "Private", "data_policy": "local_only"},
headers={"Idempotency-Key": "space-create-key"},
)
assert response.status_code == 403
assert response.json()["code"] == "RESOURCE_NOT_FOUND"
def test_user_grant_is_workspace_authorized():
app, repository, principal = _fixture()
client = TestClient(app)
space = client.post(
"/v1/knowledge-spaces",
json={"slug": "user-space", "display_name": "User space", "data_policy": "local_only"},
headers={"Idempotency-Key": "user-space-create-key"},
)
assert space.status_code == 201
user_id = uuid4()
grant = client.post(
f"/v1/knowledge-spaces/{space.json()['id']}/grants",
json={"principal_type": "user", "principal_id": str(user_id), "permissions": ["read"]},
headers={"Idempotency-Key": "user-grant-create-key"},
)
assert grant.status_code == 201
assert grant.json()["principal_type"] == "user"
assert grant.json()["principal_id"] == str(user_id)
assert len(repository.grants) == 1
assert principal.workspace_id == repository.workspace.id
@@ -0,0 +1,389 @@
from __future__ import annotations
from pathlib import Path
from uuid import UUID, uuid4
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.documents.dependencies import (
get_documents_read_principal,
get_documents_sync_principal,
)
from ragcore.application.authorization import AuthorizationService
from ragcore.application.control.api import ControlPlaneApplication
from ragcore.application.documents import (
DocumentRecord,
DocumentVersionRecord,
DocumentVersionState,
)
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.control import Role
from ragcore.main import create_app
class FakeDocumentRepository:
def __init__(self) -> None:
self.documents: dict[UUID, DocumentRecord] = {}
self.versions: dict[UUID, list[DocumentVersionRecord]] = {}
self.retired: list[UUID] = []
def seed(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
self.documents[document.id] = document
self.versions[document.id] = list(versions)
async def get_document(self, document_id: UUID) -> DocumentRecord | None:
return self.documents.get(document_id)
async def get_document_by_source(
self, workspace_id: UUID, source_key: str, external_id: str
) -> DocumentRecord | None:
return next(
(
document
for document in self.documents.values()
if document.workspace_id == workspace_id
and document.source_key == source_key
and document.external_id == external_id
),
None,
)
async def list_documents_by_source(
self, workspace_id: UUID, source_id: UUID
) -> tuple[DocumentRecord, ...]:
return tuple(
document
for document in self.documents.values()
if document.workspace_id == workspace_id and document.source_id == source_id
)
async def list_versions(self, document_id: UUID) -> tuple[DocumentVersionRecord, ...]:
return tuple(self.versions.get(document_id, ()))
async def list_searchable_versions(
self, document_id: UUID
) -> tuple[DocumentVersionRecord, ...]:
document = self.documents[document_id]
if document.source_deleted:
return ()
return tuple(
version
for version in self.versions.get(document_id, ())
if version.state is DocumentVersionState.PUBLISHED
)
async def mark_source_deleted(self, document_id: UUID) -> None:
self.retired.append(document_id)
document = self.documents[document_id]
self.documents[document_id] = DocumentRecord(
id=document.id,
workspace_id=document.workspace_id,
space_id=document.space_id,
source_key=document.source_key,
external_id=document.external_id,
title=document.title,
source_deleted=True,
)
def _document(workspace_id: UUID, *, source_key: str | None = None) -> DocumentRecord:
return DocumentRecord(
id=uuid4(),
workspace_id=workspace_id,
space_id=uuid4(),
source_key=source_key or str(uuid4()),
external_id="wiki-page-1",
title="Wiki page",
)
def _version(document_id: UUID, state: DocumentVersionState) -> DocumentVersionRecord:
return DocumentVersionRecord(
id=uuid4(),
document_id=document_id,
source_fingerprint="a" * 64,
content_hash="b" * 64,
parser_version="text-v1",
chunk_profile_version="prose-v1",
state=state,
)
def _fixture():
workspace_id = uuid4()
principal = Principal(
uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({Role.EDITOR})
)
repository = FakeDocumentRepository()
service = ControlPlaneApplication(
repository=None, # type: ignore[arg-type]
authorization=AuthorizationService(),
document_repository=repository, # type: ignore[arg-type]
)
app = create_app()
from ragcore.api.v1.control.dependencies import get_control_application
app.dependency_overrides[get_control_application] = lambda: service
app.dependency_overrides[get_documents_read_principal] = lambda: principal
app.dependency_overrides[get_documents_sync_principal] = lambda: principal
return app, repository, principal
def test_openapi_and_runtime_expose_same_document_and_job_operations() -> None:
app, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
for path in (
"/v1/documents",
"/v1/documents/{document_id}",
"/v1/documents/{document_id}/versions",
"/v1/documents/{document_id}/retire",
):
assert path in document["paths"], f"{path} missing from openapi.yaml"
documented_methods = {method.lower() for method in document["paths"][path]}
runtime_methods = {method.lower() for method in app.openapi()["paths"][path]}
assert runtime_methods == documented_methods, path
def test_list_documents_requires_a_source_id() -> None:
"""source_id alone lists the identity's documents (see
test_list_documents_by_source_returns_every_document_of_the_identity);
no filter at all is still rejected, and an unknown source is an empty page."""
app, _, _ = _fixture()
response = TestClient(app).get("/v1/documents")
assert response.status_code == 400
assert response.json()["code"] == "MISSING_FILTER"
unknown = TestClient(app).get(f"/v1/documents?source_id={uuid4()}")
assert unknown.status_code == 200
assert unknown.json() == {"items": [], "next_cursor": None}
def test_list_documents_empty_when_no_match() -> None:
app, _, _ = _fixture()
response = TestClient(app).get(f"/v1/documents?source_id={uuid4()}&external_id=missing")
assert response.status_code == 200
assert response.json() == {"items": [], "next_cursor": None}
def test_list_documents_returns_the_active_content_sha256() -> None:
app, repository, principal = _fixture()
source_id = uuid4()
document = _document(principal.workspace_id, source_key=str(source_id))
published = _version(document.id, DocumentVersionState.PUBLISHED)
repository.seed(document, published)
response = TestClient(app).get(
f"/v1/documents?source_id={source_id}&external_id={document.external_id}"
)
assert response.status_code == 200
[item] = response.json()["items"]
assert item["id"] == str(document.id)
assert item["source_id"] == str(source_id)
assert item["status"] == "active"
assert item["active_version"]["content_sha256"] == published.content_hash
def test_list_documents_cross_workspace_is_empty_not_leaked() -> None:
app, repository, _ = _fixture()
source_id = uuid4()
document = _document(uuid4(), source_key=str(source_id))
repository.seed(document)
response = TestClient(app).get(
f"/v1/documents?source_id={source_id}&external_id={document.external_id}"
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_get_document_by_id_not_found_is_problem_details() -> None:
app, _, _ = _fixture()
response = TestClient(app).get(f"/v1/documents/{uuid4()}")
assert response.status_code == 404
assert response.json()["code"] == "DOCUMENT_NOT_FOUND"
def test_get_document_versions_lists_every_state() -> None:
app, repository, principal = _fixture()
document = _document(principal.workspace_id)
superseded = _version(document.id, DocumentVersionState.SUPERSEDED)
published = _version(document.id, DocumentVersionState.PUBLISHED)
repository.seed(document, superseded, published)
response = TestClient(app).get(f"/v1/documents/{document.id}/versions")
assert response.status_code == 200
ids = {item["id"] for item in response.json()["items"]}
assert ids == {str(superseded.id), str(published.id)}
def test_retire_document_marks_source_deleted_and_is_idempotent() -> None:
app, repository, principal = _fixture()
document = _document(principal.workspace_id)
repository.seed(document)
client = TestClient(app)
first = client.post(f"/v1/documents/{document.id}/retire")
assert first.status_code == 200
assert first.json()["status"] == "source_deleted"
second = client.post(f"/v1/documents/{document.id}/retire")
assert second.status_code == 200
assert repository.retired == [document.id]
def test_retire_document_does_not_hard_delete() -> None:
app, repository, principal = _fixture()
document = _document(principal.workspace_id)
repository.seed(document)
TestClient(app).post(f"/v1/documents/{document.id}/retire")
assert document.id in repository.documents
assert repository.documents[document.id].source_deleted is True
def test_missing_authentication_is_problem_details() -> None:
app, _, _ = _fixture()
app.dependency_overrides.pop(get_documents_read_principal)
response = TestClient(app).get(f"/v1/documents?source_id={uuid4()}&external_id=x")
assert response.status_code == 401
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
def test_insufficient_role_is_forbidden_problem_details() -> None:
app, repository, principal = _fixture()
document = _document(principal.workspace_id)
repository.seed(document)
denied = Principal(
principal.id,
principal.workspace_id,
PrincipalType.SERVICE_ACCOUNT,
frozenset({Role.AUDITOR}),
)
app.dependency_overrides[get_documents_read_principal] = lambda: denied
response = TestClient(app).get(f"/v1/documents/{document.id}")
assert response.status_code == 403
assert response.json()["code"] == "RESOURCE_NOT_FOUND"
def test_reconcile_space_index_runs_the_scoped_reconciler_and_maps_errors() -> None:
from ragcore.api.v1.documents.dependencies import get_space_index_reconciler
from ragcore.application.indexes.space_reconcile import SpaceReconciliationReport
from ragcore.domain.control import KnowledgeSpace
app, repository, _principal = _fixture()
space_id = uuid4()
class FakeControlRepository:
async def list_spaces(self, workspace_id: UUID):
return (
KnowledgeSpace(
id=space_id,
workspace_id=workspace_id,
name="portfolio-public",
slug="portfolio-public",
),
)
class FakeReconciler:
def __init__(self) -> None:
self.calls: list[UUID] = []
async def reconcile(self, space, documents):
self.calls.append(space)
return SpaceReconciliationReport(
space_id=space,
documents_total=3,
documents_retired=1,
searchable_versions=2,
points_before=10,
deleted_points=4,
orphaned_documents=2,
points_after=6,
)
from ragcore.api.v1.control.dependencies import get_control_application
service = ControlPlaneApplication(
repository=FakeControlRepository(), # type: ignore[arg-type]
authorization=AuthorizationService(),
document_repository=repository, # type: ignore[arg-type]
)
app.dependency_overrides[get_control_application] = lambda: service
reconciler = FakeReconciler()
app.dependency_overrides[get_space_index_reconciler] = lambda: reconciler
client = TestClient(app)
response = client.post(f"/v1/spaces/{space_id}/index/reconcile")
assert response.status_code == 200, response.text
assert response.json() == {
"space_id": str(space_id),
"documents_total": 3,
"documents_retired": 1,
"searchable_versions": 2,
"points_before": 10,
"deleted_points": 4,
"orphaned_documents": 2,
"points_after": 6,
}
assert reconciler.calls == [space_id]
missing = client.post(f"/v1/spaces/{uuid4()}/index/reconcile")
assert missing.status_code == 404
assert missing.json()["code"] == "SPACE_NOT_FOUND"
app.dependency_overrides[get_space_index_reconciler] = lambda: None
unavailable = client.post(f"/v1/spaces/{space_id}/index/reconcile")
assert unavailable.status_code == 503
assert unavailable.json()["code"] == "INDEX_UNAVAILABLE"
def test_openapi_documents_the_reconcile_endpoint() -> None:
app, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
path = "/v1/spaces/{space_id}/index/reconcile"
assert path in document["paths"]
assert {m.lower() for m in app.openapi()["paths"][path]} == {"post"}
def test_list_documents_by_source_returns_every_document_of_the_identity() -> None:
app, repository, principal = _fixture()
source_id = uuid4()
first = _document(principal.workspace_id, source_key=str(source_id))
second = DocumentRecord(
id=uuid4(),
workspace_id=principal.workspace_id,
space_id=first.space_id,
source_key=str(source_id),
external_id="wiki-page-2",
title="Second page",
source_deleted=True,
)
other = _document(principal.workspace_id) # another source identity, must not appear
repository.seed(first, _version(first.id, DocumentVersionState.PUBLISHED))
repository.seed(second, _version(second.id, DocumentVersionState.PUBLISHED))
repository.seed(other, _version(other.id, DocumentVersionState.PUBLISHED))
response = TestClient(app).get(f"/v1/documents?source_id={source_id}")
assert response.status_code == 200, response.text
items = response.json()["items"]
assert {item["external_id"] for item in items} == {"wiki-page-1", "wiki-page-2"}
by_id = {item["external_id"]: item for item in items}
assert by_id["wiki-page-1"]["status"] == "active"
assert by_id["wiki-page-1"]["active_version"]["source_fingerprint"] == "a" * 64
assert by_id["wiki-page-2"]["status"] == "source_deleted"
assert by_id["wiki-page-2"]["active_version"] is None
missing = TestClient(app).get("/v1/documents")
assert missing.status_code == 400
assert missing.json()["code"] == "MISSING_FILTER"
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.jobs.dependencies import get_job_service, get_jobs_sync_principal
from ragcore.application.jobs import JobService
from ragcore.application.jobs.models import Job
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.control import Role
from ragcore.main import create_app
class FakeJobRepository:
def __init__(self) -> None:
self.jobs: dict[UUID, Job] = {}
async def enqueue(self, job: Job) -> Job:
self.jobs[job.id] = job
return job
async def get(self, job_id: UUID) -> Job | None:
return self.jobs.get(job_id)
async def get_owned(self, job_id: UUID, *, workspace_id: UUID | None) -> Job | None:
job = self.jobs.get(job_id)
if job is None:
return None
if workspace_id is not None and job.workspace_id != workspace_id:
return None
return job
def _job(*, workspace_id: UUID | None = None, **overrides: object) -> Job:
payload: dict[str, object] = {"blob_digest": "a" * 64}
if workspace_id is not None:
payload["workspace_id"] = str(workspace_id)
defaults: dict[str, object] = dict(
id=uuid4(),
workspace_id=workspace_id,
job_key=f"job-{uuid4()}",
operation="ingest_upload",
target_key="blob:abc",
profile_version="upload-v1",
payload=payload,
available_at=datetime.now(UTC),
trace_id="trace",
)
defaults.update(overrides)
return Job(**defaults) # type: ignore[arg-type]
def _fixture():
workspace_id = uuid4()
principal = Principal(
uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({Role.EDITOR})
)
repository = FakeJobRepository()
app = create_app()
app.dependency_overrides[get_job_service] = lambda: JobService(repository) # type: ignore[arg-type]
app.dependency_overrides[get_jobs_sync_principal] = lambda: principal
return app, repository, principal
def test_openapi_and_runtime_expose_same_job_operations() -> None:
app, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
assert "/v1/jobs/{job_id}" in document["paths"]
documented = {method.lower() for method in document["paths"]["/v1/jobs/{job_id}"]}
runtime = {method.lower() for method in app.openapi()["paths"]["/v1/jobs/{job_id}"]}
assert runtime == documented
def test_get_job_resolves_the_upload_status_url() -> None:
app, repository, principal = _fixture()
job = _job(workspace_id=principal.workspace_id)
asyncio.run(repository.enqueue(job))
response = TestClient(app).get(f"/v1/jobs/{job.id}")
assert response.status_code == 200
body = response.json()
assert body["id"] == str(job.id)
assert body["operation"] == "ingest_upload"
assert body["status"] == "ready"
assert body["attempt_count"] == 0
assert body["max_attempts"] == 3
def test_get_job_unknown_id_is_404_problem_details() -> None:
app, _, _ = _fixture()
response = TestClient(app).get(f"/v1/jobs/{uuid4()}")
assert response.status_code == 404
assert response.json()["code"] == "JOB_NOT_FOUND"
def test_get_job_cross_workspace_is_404_not_leaked() -> None:
app, repository, _ = _fixture()
job = _job(workspace_id=uuid4())
asyncio.run(repository.enqueue(job))
response = TestClient(app).get(f"/v1/jobs/{job.id}")
assert response.status_code == 404
assert response.json()["code"] == "JOB_NOT_FOUND"
def test_get_job_without_ownership_is_404_for_a_workspace_scoped_caller() -> None:
"""A job with no owner is nobody's, not everyone's.
This route used to accept any job whose payload simply lacked a
``workspace_id`` -- a fail-open default that handed unowned work to
whichever tenant asked for it first. Ownership is a column now, and an
absent owner (a legacy row that migration 0018 would not guess at) reads
exactly like a job that does not exist.
"""
app, repository, _ = _fixture()
job = _job(workspace_id=None)
asyncio.run(repository.enqueue(job))
response = TestClient(app).get(f"/v1/jobs/{job.id}")
assert response.status_code == 404
assert response.json()["code"] == "JOB_NOT_FOUND"
def test_missing_authentication_is_problem_details() -> None:
app, _, _ = _fixture()
app.dependency_overrides.pop(get_jobs_sync_principal)
response = TestClient(app).get(f"/v1/jobs/{uuid4()}")
assert response.status_code == 401
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
@@ -0,0 +1,278 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
import pytest
import yaml
from fastapi.testclient import TestClient
from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal
from ragcore.api.v1.search.service import (
AuthorizationInputs,
SearchExecutionRequest,
SearchExecutionResult,
SearchHit,
SearchService,
)
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Workspace
from ragcore.main import create_app
from ragcore.retrieval.ranking import FusedCandidate
from ragcore.retrieval.search.models import (
Candidate,
CandidateBackendKind,
CandidateProvenance,
)
class FixtureExecutor:
def __init__(self, hits: tuple[SearchHit, ...] = (), **metadata: object) -> None:
self.hits = hits
self.metadata = metadata
self.calls: list[SearchExecutionRequest] = []
async def execute(self, request: SearchExecutionRequest) -> SearchExecutionResult:
self.calls.append(request)
hits = tuple(
replace(
hit,
citation=Citation.create(
workspace_id=hit.citation.workspace_id,
space_id=hit.citation.space_id,
document_id=hit.citation.document_id,
document_version_id=hit.citation.document_version_id,
chunk_id=hit.citation.chunk_id,
parent_chunk_id=hit.citation.parent_chunk_id,
location=hit.citation.location,
excerpt=hit.citation.excerpt,
source_uri=hit.citation.source_uri,
acquired_at=hit.citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=hit.citation.index_alias,
profile_version=hit.citation.profile_version,
),
)
for hit in self.hits
)
return SearchExecutionResult(hits=hits, **self.metadata)
class FixtureInputs:
def __init__(
self,
workspace: Workspace,
principal: Principal,
space_id: UUID,
*,
application: Application | None = None,
) -> None:
self.workspace = workspace
self.principal = principal
self.space_id = space_id
self.application = application
async def load(self, principal: Principal) -> AuthorizationInputs:
return AuthorizationInputs(
spaces=(self.workspace.create_knowledge_space(
id=self.space_id,
name="Search space",
slug="search-space",
),),
user_grants=(
self.workspace.grant_user(
id=uuid4(),
user_id=principal.id,
knowledge_space=self.workspace.create_knowledge_space(
id=self.space_id,
name="Search space",
slug="search-space",
),
role=Role.VIEWER,
),
),
application=self.application,
)
def _fixture(
*,
text: str = "Exact evidence excerpt.",
query: str = "exact",
metadata: dict[str, object] | None = None,
) -> tuple[object, Principal, UUID, SearchHit, FixtureExecutor]:
workspace = Workspace(uuid4(), "Search workspace", "search-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER}))
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
parent_chunk_id=None,
location={"page_start": 2, "page_end": 2, "section": "Runbook"},
excerpt=text,
source_uri="https://example.test/runbook.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = Candidate(
point_id=uuid4(),
score=0.91,
rank=1,
backend=CandidateBackendKind.DENSE,
provenance=CandidateProvenance(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
source_id=uuid4(),
parent_chunk_id=None,
status="published",
content_type="text/markdown",
language="nl",
trust_level="trusted_internal",
),
)
hit = SearchHit(
fused=FusedCandidate(candidate, 1 / 61, 1, None),
text=text,
title="Runbook",
citation=citation,
dense_score=0.91,
)
app = Application(
id=uuid4(),
workspace_id=workspace.id,
name="Search application",
default_knowledge_space_ids=frozenset({space_id}),
)
executor = FixtureExecutor((hit,), **(metadata or {}))
service = SearchService(
authorization=RetrievalAuthorizationService(),
inputs=FixtureInputs(workspace, principal, space_id, application=app),
executor=executor,
)
api = create_app()
api.dependency_overrides[get_search_principal] = lambda: principal
api.dependency_overrides[get_search_application] = lambda: service
return api, principal, space_id, hit, executor
def test_openapi_and_runtime_expose_search_operation() -> None:
api, _, _, _, _ = _fixture()
document = yaml.safe_load(
(Path(__file__).parents[4] / "docs" / "contracts" / "openapi.yaml").read_text()
)
assert set(api.openapi()["paths"]["/v1/search"]) == {"post"}
assert "post" in document["paths"]["/v1/search"]
@pytest.mark.parametrize("query", ["exact", "semantic"])
def test_exact_and_semantic_fixtures_return_citations(query: str) -> None:
api, _, space_id, _, executor = _fixture(query=query)
response = TestClient(api).post(
"/v1/search",
json={"query": query, "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200
body = response.json()
assert body["effective_space_ids"] == [str(space_id)]
assert body["results"][0]["citation"]["excerpt_sha256"]
assert executor.calls[0].scope.effective_space_ids == frozenset({space_id})
def test_requested_spaces_cannot_expand_effective_scope() -> None:
api, _, _, _, executor = _fixture()
forbidden_space = uuid4()
response = TestClient(api).post(
"/v1/search",
json={"query": "exact", "requested_space_ids": [str(forbidden_space)]},
)
assert response.status_code == 200
assert response.json()["effective_space_ids"] == []
assert response.json()["results"] == []
assert executor.calls == []
def test_degraded_metadata_is_stable() -> None:
api, _, space_id, _, _ = _fixture(
metadata={
"degraded": True,
"degradation_codes": ("backend_timeout",),
"timings_ms": {"dense": 12.5, "sparse": 5.0},
}
)
response = TestClient(api).post(
"/v1/search",
json={"query": "exact", "requested_space_ids": [str(space_id)]},
)
assert response.status_code == 200
assert response.json()["degraded"] is True
assert response.json()["degradation_codes"] == ["backend_timeout"]
assert response.json()["timings_ms"] == {"dense": 12.5, "sparse": 5.0}
def test_invalid_filter_duplicates_return_problem_details() -> None:
api, _, _, _, _ = _fixture()
response = TestClient(api).post(
"/v1/search",
json={"query": "exact", "filters": {"languages": ["nl", "nl"]}},
)
assert response.status_code == 422
assert response.json()["code"] == "VALIDATION_ERROR"
def test_missing_search_service_is_503_problem_details() -> None:
api, principal, _, _, _ = _fixture()
# create_app() always wires a real search_application now; popping the
# dependency override alone would just fall through to that real service
# instead of exercising get_search_application's own missing-state
# fallback, so the state attribute itself has to be genuinely absent.
api.dependency_overrides.pop(get_search_application)
del api.state.search_application
response = TestClient(api).post("/v1/search", json={"query": "exact"})
assert response.status_code == 503
assert response.json()["code"] == "SEARCH_UNAVAILABLE"
assert principal.workspace_id
def test_debug_output_requires_admin_capability() -> None:
api, _, space_id, _, _ = _fixture()
response = TestClient(api).post(
"/v1/search",
json={
"query": "exact",
"requested_space_ids": [str(space_id)],
"include_debug": True,
},
)
assert response.status_code == 403
assert response.json()["code"] == "DEBUG_NOT_ALLOWED"
def test_mismatched_profile_provenance_is_rejected() -> None:
api, _, space_id, _, _ = _fixture()
response = TestClient(api).post(
"/v1/search",
json={
"query": "exact",
"profile": "other-v1",
"requested_space_ids": [str(space_id)],
},
)
assert response.status_code == 503
assert response.json()["code"] == "UNAUTHORIZED_OR_MISMATCHED_RESULT"
+376
View File
@@ -0,0 +1,376 @@
"""Fixtures for the MCP contract tests.
Mirrors the same fixture-construction pattern as `tests/contract/api/**`
and `tests/contract/sdk/python/**` (real `RetrievalAuthorizationService`,
a fixture executor/generator standing in only for the retrieval
backend/model provider) -- these tests exist to prove the MCP adapter's
own auth/mapping/rate-limit layer, not to re-prove authorization/
retrieval business logic already covered there.
"""
from __future__ import annotations
import contextlib
from collections.abc import AsyncIterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4
import httpx2
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import MCPServer
from ragcore.api.v1.answers.service import AnswerService
from ragcore.api.v1.context.service import (
ContextExecutionRequest,
ContextExecutionResult,
ContextService,
)
from ragcore.api.v1.search.service import (
AuthorizationInputs,
SearchHit,
SearchService,
)
from ragcore.application.identity.credentials import ServiceAccountCredentialService
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Scope, ServiceAccount, Workspace
from ragcore.infrastructure.database.identity import InMemoryCredentialRepository
from ragcore.mcp.server import MCPDependencies, build_mcp_server
from ragcore.retrieval.context import ContextCandidate
from ragcore.retrieval.ranking import FusedCandidate
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date."
CREDENTIAL_PEPPER = "test-credential-pepper-that-is-long-enough"
class InMemoryDocumentRepository:
def __init__(self) -> None:
self._documents: dict[UUID, Any] = {}
self._versions: dict[UUID, Any] = {}
def add_document(self, document: Any) -> None:
self._documents[document.id] = document
def add_version(self, version: Any) -> None:
self._versions[version.id] = version
async def get_document(self, document_id: UUID) -> Any:
return self._documents.get(document_id)
async def get_version(self, version_id: UUID) -> Any:
return self._versions.get(version_id)
async def list_searchable_versions(self, document_id: UUID) -> tuple[Any, ...]:
from ragcore.application.documents.models import DocumentVersionState
return tuple(
v
for v in self._versions.values()
if v.document_id == document_id and v.state is DocumentVersionState.PUBLISHED
)
class InMemoryCitationRepository:
def __init__(self) -> None:
self._citations: dict[UUID, Citation] = {}
def add(self, citation: Citation) -> None:
self._citations[citation.id] = citation
async def get_authorized(
self, citation_id: UUID, *, workspace_id: UUID, effective_space_ids: frozenset[UUID]
) -> Citation | None:
citation = self._citations.get(citation_id)
if citation is None or citation.workspace_id != workspace_id:
return None
if citation.space_id not in effective_space_ids:
return None
return citation
class _FixtureInputs:
"""Loads the one space plus a real workspace-level `Grant` tying it to
`application` -- `EffectiveSpaceResolver` requires an actual `Grant`
row (not just `Application.default_knowledge_space_ids`, which is only
consulted when the caller's own `requested_space_ids` is empty) to
intersect an *explicit* request against for a SERVICE_ACCOUNT
principal, exactly like a real workspace-level grant REST would use.
"""
def __init__(self, workspace: Workspace, space: Any, application: Application) -> None:
self.workspace = workspace
self.space = space
self.application = application
self.grant = workspace.grant(
id=uuid4(), application=application, knowledge_space=space, role=Role.EDITOR
)
async def load(self, principal: Principal) -> Any:
return type(
"Inputs",
(),
{
"spaces": (self.space,),
"grants": (self.grant,),
"user_grants": (),
"application": self.application,
},
)()
@dataclass
class McpFixture:
server: MCPServer
deps: MCPDependencies
workspace: Workspace
application: Application
space_id: UUID
token: str
document_repository: InMemoryDocumentRepository
citation_repository: InMemoryCitationRepository
def _citation(workspace_id: UUID, space_id: UUID) -> Citation:
return Citation.create(
workspace_id=workspace_id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 4, "section": "Refunds"},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/policy.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
async def build_mcp_fixture(*, grant_scopes: set[Scope] | None = None) -> McpFixture:
workspace = Workspace(uuid4(), "MCP workspace", "mcp-workspace")
space_id = uuid4()
space = workspace.create_knowledge_space(id=space_id, name="MCP space", slug="mcp-space")
application = workspace.create_application(
id=uuid4(),
name="MCP client",
slug="mcp-client",
scopes=frozenset(
grant_scopes
or {
Scope.SEARCH,
Scope.CONTEXT,
Scope.ANSWER,
Scope.DOCUMENTS_READ,
Scope.CITATIONS_READ,
}
),
default_knowledge_space_ids=frozenset({space_id}),
)
service_account = ServiceAccount(
id=uuid4(),
workspace_id=workspace.id,
application_id=application.id,
name="MCP service account",
scopes=application.scopes,
)
async def load_service_account(account_id: UUID) -> ServiceAccount | None:
return service_account if account_id == service_account.id else None
credential_service = ServiceAccountCredentialService(
InMemoryCredentialRepository(),
CREDENTIAL_PEPPER,
service_account_loader=load_service_account,
)
issued = await credential_service.issue(service_account, scopes=service_account.scopes)
citation = _citation(workspace.id, space_id)
candidate = Candidate(
point_id=uuid4(),
score=0.87,
rank=1,
backend=CandidateBackendKind.DENSE,
provenance=CandidateProvenance(
workspace_id=workspace.id,
space_id=space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
source_id=uuid4(),
parent_chunk_id=None,
status="published",
content_type="text/markdown",
language="en",
trust_level="trusted_internal",
),
)
hit = SearchHit(
fused=FusedCandidate(candidate, 1 / 61, 1, None),
text=EVIDENCE_EXCERPT,
title="Refund policy",
citation=citation,
dense_score=0.87,
)
class _SearchExecutor:
async def execute(self, request: Any) -> Any:
refreshed = replace(
hit,
citation=Citation.create(
workspace_id=hit.citation.workspace_id,
space_id=hit.citation.space_id,
document_id=hit.citation.document_id,
document_version_id=hit.citation.document_version_id,
chunk_id=hit.citation.chunk_id,
parent_chunk_id=hit.citation.parent_chunk_id,
location=hit.citation.location,
excerpt=hit.citation.excerpt,
source_uri=hit.citation.source_uri,
acquired_at=hit.citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=hit.citation.index_alias,
profile_version=hit.citation.profile_version,
),
)
return type(
"Result",
(),
{
"hits": (refreshed,),
"degraded": False,
"degradation_codes": (),
"timings_ms": {},
},
)()
inputs = _FixtureInputs(workspace, space, application)
search_service = SearchService(
authorization=RetrievalAuthorizationService(),
inputs=inputs,
executor=_SearchExecutor(),
)
context_candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
class _ContextExecutor:
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(
candidates=(replace(context_candidate, citation=refreshed),)
)
context_service = ContextService(
authorization=RetrievalAuthorizationService(), inputs=inputs, executor=_ContextExecutor()
)
import json
import re
citation_id_pattern = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
class _Generator:
async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str:
match = citation_id_pattern.search(prompt)
assert match is not None
return json.dumps(
{
"status": "answerable",
"answer": "The refund window is thirty days from purchase.",
"citations": [match.group(1)],
}
)
answer_service = AnswerService(
authorization=RetrievalAuthorizationService(),
inputs=inputs,
context_executor=_ContextExecutor(),
generator=_Generator(),
)
document_repository = InMemoryDocumentRepository()
citation_repository = InMemoryCitationRepository()
citation_repository.add(citation)
class _AuthorizationInputsForMcp:
async def load(self, principal: Principal) -> AuthorizationInputs:
loaded = await inputs.load(principal)
return AuthorizationInputs(
spaces=loaded.spaces,
grants=loaded.grants,
user_grants=loaded.user_grants,
application=loaded.application,
)
deps = MCPDependencies(
search_application=search_service,
context_application=context_service,
answer_application=answer_service,
authorization_inputs=_AuthorizationInputsForMcp(),
credential_service=credential_service,
citation_repository=citation_repository,
document_repository=document_repository,
)
server = build_mcp_server(deps)
return McpFixture(
server=server,
deps=deps,
workspace=workspace,
application=application,
space_id=space_id,
token=issued.token,
document_repository=document_repository,
citation_repository=citation_repository,
)
_MCP_TEST_URL = "http://127.0.0.1:8765"
@contextlib.asynccontextmanager
async def mcp_session(
server: MCPServer, *, token: str | None = None
) -> AsyncIterator[ClientSession]:
"""A real streamable-HTTP MCP session over `httpx2.ASGITransport` -- genuine
Starlette `Request`/header handling, no real TCP socket needed.
`ASGITransport` never runs the ASGI app's own lifespan protocol (that's
normally what starts the session manager's task group when a real
server like uvicorn owns the app), so `session_manager.run()` is
entered explicitly here instead, mirroring what a real deployment's
lifespan would do.
"""
app = server.streamable_http_app(stateless_http=True)
headers = {"authorization": f"Bearer {token}"} if token else {}
async with (
server.session_manager.run(),
httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app), base_url=_MCP_TEST_URL, headers=headers
) as http_client,
streamable_http_client(f"{_MCP_TEST_URL}/mcp", http_client=http_client) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
yield session
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from dataclasses import replace
from mcp_fixtures import build_mcp_fixture, mcp_session
from ragcore.application.audit.service import AuditEventService
from ragcore.application.limits.models import RateLimitRule
from ragcore.application.limits.rate_limiter import RateLimiter, RateLimitRegistry
from ragcore.domain.control import Scope
from ragcore.mcp.server import build_mcp_server
EXPECTED_TOOLS = {
"rag_search",
"rag_get_context",
"rag_answer",
"rag_list_spaces",
"rag_get_document",
"rag_get_citation",
}
async def test_tool_surface_is_exactly_the_agreed_read_only_tools() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=fixture.token) as session:
listed = await session.list_tools()
names = {tool.name for tool in listed.tools}
assert names == EXPECTED_TOOLS
for tool in listed.tools:
assert tool.annotations is not None
assert tool.annotations.read_only_hint is True
assert tool.annotations.destructive_hint is False
async def test_rag_search_returns_typed_results() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool(
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
)
assert result.is_error is not True
body = result.structured_content
assert body["effective_space_ids"] == [str(fixture.space_id)]
assert body["results"][0]["citation"]["excerpt_sha256"]
async def test_rag_get_context_returns_typed_blocks() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool(
"rag_get_context", {"query": "refund", "spaces": [str(fixture.space_id)]}
)
assert result.is_error is not True
body = result.structured_content
assert body["citations"]
assert body["blocks"][0]["citation_ids"]
async def test_rag_answer_returns_grounded_answer() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool(
"rag_answer", {"query": "refund window", "spaces": [str(fixture.space_id)]}
)
assert result.is_error is not True
body = result.structured_content
assert body["answer"]
assert body["citations"]
assert all(claim["citation_ids"] for claim in body["claims"])
async def test_rag_list_spaces_lists_only_granted_spaces() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool("rag_list_spaces", {})
assert result.is_error is not True
spaces = result.structured_content["result"]
assert [s["id"] for s in spaces] == [str(fixture.space_id)]
async def test_rag_get_document_returns_the_published_version() -> None:
from datetime import UTC, datetime
from uuid import uuid4
from ragcore.application.documents.models import (
DocumentRecord,
DocumentVersionRecord,
DocumentVersionState,
)
fixture = await build_mcp_fixture()
document = DocumentRecord(
id=uuid4(),
workspace_id=fixture.workspace.id,
space_id=fixture.space_id,
source_key="policy.md",
external_id="policy",
title="Refund policy",
)
version = DocumentVersionRecord(
id=uuid4(),
document_id=document.id,
source_fingerprint="fp",
content_hash="hash",
parser_version="v1",
chunk_profile_version="v1",
state=DocumentVersionState.PUBLISHED,
created_at=datetime(2026, 8, 1, tzinfo=UTC),
updated_at=datetime(2026, 8, 1, tzinfo=UTC),
)
fixture.document_repository.add_document(document)
fixture.document_repository.add_version(version)
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool("rag_get_document", {"document_id": str(document.id)})
assert result.is_error is not True
body = result.structured_content
assert body["id"] == str(document.id)
assert body["version"]["id"] == str(version.id)
async def test_rag_get_document_hides_a_document_outside_granted_spaces() -> None:
from uuid import uuid4
from ragcore.application.documents.models import DocumentRecord
fixture = await build_mcp_fixture()
other_space_document = DocumentRecord(
id=uuid4(),
workspace_id=fixture.workspace.id,
space_id=uuid4(),
source_key="secret.md",
external_id="secret",
title="Not granted",
)
fixture.document_repository.add_document(other_space_document)
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool(
"rag_get_document", {"document_id": str(other_space_document.id)}
)
assert result.is_error is True
async def test_rag_get_citation_returns_a_granted_citation() -> None:
fixture = await build_mcp_fixture()
(citation,) = fixture.citation_repository._citations.values()
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool("rag_get_citation", {"citation_id": str(citation.id)})
assert result.is_error is not True
assert result.structured_content["id"] == str(citation.id)
async def test_rag_get_citation_hides_an_out_of_scope_citation() -> None:
from uuid import uuid4
fixture = await build_mcp_fixture()
outside = fixture.citation_repository
from mcp_fixtures import _citation
stray = _citation(fixture.workspace.id, uuid4())
outside.add(stray)
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool("rag_get_citation", {"citation_id": str(stray.id)})
assert result.is_error is True
async def test_no_token_is_rejected() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token=None) as session:
result = await session.call_tool("rag_search", {"query": "refund"})
assert result.is_error is True
async def test_wrong_scope_token_is_rejected() -> None:
fixture = await build_mcp_fixture(grant_scopes={Scope.SEARCH})
async with mcp_session(fixture.server, token=fixture.token) as session:
result = await session.call_tool("rag_answer", {"query": "refund"})
assert result.is_error is True
async def test_malformed_token_is_rejected() -> None:
fixture = await build_mcp_fixture()
async with mcp_session(fixture.server, token="not-a-real-token") as session:
result = await session.call_tool("rag_search", {"query": "refund"})
assert result.is_error is True
async def test_rate_limit_is_enforced_via_the_shared_rate_limiter() -> None:
fixture = await build_mcp_fixture()
# A default rule (keyed by operation only, no subject) applies to any
# caller -- the same `RateLimitRegistry` primitive M6-02 built for REST,
# reused unchanged here. `AuditEventService`'s repository is never
# touched: only `set_override` (not used by this test) calls `.record()`.
registry = RateLimitRegistry(
default_rules={"mcp:rag_search": RateLimitRule(max_requests=1, window_seconds=60.0)},
audit=AuditEventService(repository=None), # type: ignore[arg-type]
)
limiter = RateLimiter(registry=registry)
limited_server = build_mcp_server(replace(fixture.deps, rate_limiter=limiter))
async with mcp_session(limited_server, token=fixture.token) as session:
first = await session.call_tool(
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
)
second = await session.call_tool(
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
)
assert first.is_error is not True
assert second.is_error is True
@@ -0,0 +1,125 @@
"""One real MCP smoke flow: ragcore.mcp.main's own composition root, real
Postgres and real Qdrant -- not just the fixture-backed protocol tests in
test_mcp.py (which deliberately stay on fake repositories to prove the MCP
adapter's own auth/mapping/rate-limit layer, not re-prove infrastructure
wiring). This proves the actual production dependency graph
(``build_dependencies``/``build_mcp_server``) authenticates a real bearer
credential against real Postgres and serves a real tool call end to end.
"""
from __future__ import annotations
import asyncio
import sys
from collections.abc import AsyncIterator
from uuid import uuid4
import httpx
import pytest
from mcp_fixtures import mcp_session
from qdrant_client import AsyncQdrantClient
from sqlalchemy.ext.asyncio import AsyncEngine
from ragcore.application.identity.credentials import ServiceAccountCredentialService
from ragcore.config import Settings
from ragcore.domain.control import Scope, ServiceAccount, Workspace
from ragcore.infrastructure.database import create_database_engine, create_session_factory
from ragcore.infrastructure.database.control import ControlPlaneRepository
from ragcore.infrastructure.database.identity.repository import PostgresCredentialRepository
from ragcore.mcp.main import build_dependencies
from ragcore.mcp.server import build_mcp_server
from ragcore.platform.telemetry import configure_telemetry
pytestmark = pytest.mark.integration
_TEST_PEPPER = "test-credential-pepper-that-is-long-enough-for-real"
@pytest.fixture(scope="session")
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
if sys.platform == "win32":
return asyncio.WindowsSelectorEventLoopPolicy()
return asyncio.get_event_loop_policy()
@pytest.fixture
async def db_engine() -> AsyncIterator[AsyncEngine]:
engine = create_database_engine(Settings(env="test"))
try:
yield engine
finally:
await engine.dispose()
async def test_real_composition_root_authenticates_and_serves_a_real_tool_call(
db_engine: AsyncEngine,
) -> None:
session_factory = create_session_factory(db_engine)
suffix = uuid4().hex[:8]
async with session_factory() as session, session.begin():
control = ControlPlaneRepository(session)
workspace = await control.create_workspace(
Workspace(uuid4(), "MCP Real Root", f"mcp-real-root-{suffix}")
)
application = await control.create_application(
workspace.create_application(
id=uuid4(),
name="MCP Real Root Client",
slug=f"mcp-real-root-client-{suffix}",
scopes=frozenset({Scope.SEARCH, Scope.CONTEXT, Scope.ANSWER}),
)
)
service_account = await control.create_service_account(
ServiceAccount(
id=uuid4(),
workspace_id=workspace.id,
application_id=application.id,
name="MCP Real Root Service Account",
scopes=application.scopes,
)
)
credential_service = ServiceAccountCredentialService(
PostgresCredentialRepository(session),
_TEST_PEPPER,
control.get_service_account_by_id,
)
issued = await credential_service.issue(service_account, scopes=service_account.scopes)
# Same pepper the credential above was issued with, so
# build_dependencies' own session-scoped credential service (which reads
# settings.api_token_pepper) can verify it for real.
settings = Settings(env="test", api_token_pepper=_TEST_PEPPER)
qdrant_client = AsyncQdrantClient(
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=30
)
http_client = httpx.AsyncClient(follow_redirects=False)
telemetry = configure_telemetry(settings, service_name="test-mcp")
try:
deps = build_dependencies(
settings,
engine=db_engine,
qdrant_client=qdrant_client,
http_client=http_client,
telemetry=telemetry,
)
server = build_mcp_server(deps)
async with mcp_session(server, token=issued.token) as mcp_client_session:
listed = await mcp_client_session.list_tools()
result = await mcp_client_session.call_tool("rag_list_spaces", {})
finally:
await http_client.aclose()
await qdrant_client.close()
telemetry.shutdown()
assert {tool.name for tool in listed.tools} == {
"rag_search",
"rag_get_context",
"rag_answer",
"rag_list_spaces",
"rag_get_document",
"rag_get_citation",
}
assert result.is_error is not True
+399
View File
@@ -0,0 +1,399 @@
"""Fixture FastAPI apps for the SDK contract tests.
Mirrors the same fixture-construction pattern used by the existing
`tests/contract/api/{search,context,answers}` contract tests (real
`RetrievalAuthorizationService`, a fixture executor/generator standing in
only for the retrieval backend/model provider) -- these tests exist to
prove the SDK's HTTP layer (request shape, response typing, SSE parsing,
Problem Details mapping, retries), not to re-prove authorization/retrieval
business logic that is already covered there.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import re
import socket
import sys
import threading
import time
from collections.abc import Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4
import uvicorn
from fastapi import FastAPI
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
from ragcore.api.v1.answers.service import AnswerService
from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal
from ragcore.api.v1.context.service import (
ContextExecutionRequest,
ContextExecutionResult,
ContextService,
)
from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal
from ragcore.api.v1.search.service import SearchHit, SearchQuery, SearchService
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Workspace
from ragcore.main import create_app
from ragcore.retrieval.context import ContextCandidate
from ragcore.retrieval.ranking import FusedCandidate
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date."
class _FixtureInputs:
def __init__(self, workspace: Workspace, space_id: UUID, space_name: str) -> None:
self.workspace = workspace
self.space_id = space_id
self.space_name = space_name
async def load(self, principal: Principal) -> Any:
space = self.workspace.create_knowledge_space(
id=self.space_id, name=self.space_name, slug=self.space_name.lower().replace(" ", "-")
)
return type(
"Inputs",
(),
{
"spaces": (space,),
"grants": (),
"user_grants": (
self.workspace.grant_user(
id=uuid4(), user_id=principal.id, knowledge_space=space, role=Role.EDITOR
),
),
"application": Application(
id=uuid4(),
workspace_id=self.workspace.id,
name=f"{self.space_name} application",
default_knowledge_space_ids=frozenset({self.space_id}),
),
},
)()
@dataclass
class SearchFixture:
app: FastAPI
principal: Principal
space_id: UUID
calls: list[SearchQuery]
def build_search_app() -> SearchFixture:
workspace = Workspace(uuid4(), "SDK search workspace", "sdk-search-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER}))
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
parent_chunk_id=None,
location={"page_start": 4, "page_end": 4, "section": "Refunds"},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/policy.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = Candidate(
point_id=uuid4(),
score=0.87,
rank=1,
backend=CandidateBackendKind.DENSE,
provenance=CandidateProvenance(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
source_id=uuid4(),
parent_chunk_id=None,
status="published",
content_type="text/markdown",
language="en",
trust_level="trusted_internal",
),
)
hit = SearchHit(
fused=FusedCandidate(candidate, 1 / 61, 1, None),
text=EVIDENCE_EXCERPT,
title="Refund policy",
citation=citation,
dense_score=0.87,
)
calls: list[SearchQuery] = []
class _Executor:
async def execute(self, request: Any) -> Any:
refreshed = replace(
hit,
citation=Citation.create(
workspace_id=hit.citation.workspace_id,
space_id=hit.citation.space_id,
document_id=hit.citation.document_id,
document_version_id=hit.citation.document_version_id,
chunk_id=hit.citation.chunk_id,
parent_chunk_id=hit.citation.parent_chunk_id,
location=hit.citation.location,
excerpt=hit.citation.excerpt,
source_uri=hit.citation.source_uri,
acquired_at=hit.citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=hit.citation.index_alias,
profile_version=hit.citation.profile_version,
),
)
return type(
"Result",
(),
{
"hits": (refreshed,),
"degraded": False,
"degradation_codes": (),
"timings_ms": {},
},
)()
application = Application(
id=uuid4(),
workspace_id=workspace.id,
name="SDK search application",
default_knowledge_space_ids=frozenset({space_id}),
)
class _Inputs(_FixtureInputs):
async def load(self, principal: Principal) -> Any:
base = await super().load(principal)
return type(
"Inputs",
(),
{
"spaces": base.spaces,
"grants": base.grants,
"user_grants": base.user_grants,
"application": application,
},
)()
service = SearchService(
authorization=RetrievalAuthorizationService(),
inputs=_Inputs(workspace, space_id, "SDK search space"),
executor=_Executor(),
)
app = create_app()
app.dependency_overrides[get_search_principal] = lambda: principal
app.dependency_overrides[get_search_application] = lambda: service
return SearchFixture(app=app, principal=principal, space_id=space_id, calls=calls)
@dataclass
class ContextFixture:
app: FastAPI
principal: Principal
space_id: UUID
def build_context_app() -> ContextFixture:
workspace = Workspace(uuid4(), "SDK context workspace", "sdk-context-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/context.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
class _Executor:
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),))
service = ContextService(
authorization=RetrievalAuthorizationService(),
inputs=_FixtureInputs(workspace, space_id, "SDK context space"),
executor=_Executor(),
)
app = create_app()
app.dependency_overrides[get_context_principal] = lambda: principal
app.dependency_overrides[get_context_application] = lambda: service
return ContextFixture(app=app, principal=principal, space_id=space_id)
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
@dataclass
class AnswerFixture:
app: FastAPI
principal: Principal
space_id: UUID
def build_answer_app(*, generator_responses: list[str | None] | None = None) -> AnswerFixture:
workspace = Workspace(uuid4(), "SDK answers workspace", "sdk-answers-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/answers.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
class _ContextExecutor:
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),))
responses = list(generator_responses if generator_responses is not None else [None])
class _Generator:
async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str:
response = responses.pop(0) if responses else None
if response is not None:
return response
match = _CITATION_ID.search(prompt)
assert match is not None
return json.dumps(
{
"status": "answerable",
"answer": "The refund window is thirty days from purchase.",
"citations": [match.group(1)],
}
)
service = AnswerService(
authorization=RetrievalAuthorizationService(),
inputs=_FixtureInputs(workspace, space_id, "SDK answers space"),
context_executor=_ContextExecutor(),
generator=_Generator(),
)
app = create_app()
app.dependency_overrides[get_answer_principal] = lambda: principal
app.dependency_overrides[get_answer_application] = lambda: service
return AnswerFixture(app=app, principal=principal, space_id=space_id)
def _serve_forever(server: uvicorn.Server, sock: socket.socket) -> None:
# Deliberately not `server.run()`: that wraps `server.serve()` in
# `asyncio.run(..., loop_factory=...)`, and uvicorn's own loop factory
# (`uvicorn.loops.asyncio.asyncio_loop_factory`) hardcodes
# `asyncio.ProactorEventLoop` on Windows -- which the real app's
# lifespan (an async psycopg connection) refuses to run under, no
# matter what global event-loop policy is set beforehand. Driving the
# loop directly sidesteps uvicorn's own hardcoded choice.
loop = asyncio.SelectorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(server.serve(sockets=[sock]))
finally:
loop.close()
@contextlib.contextmanager
def run_app(app: FastAPI) -> Iterator[str]:
"""Serve `app` over a real local TCP socket for the duration of the block.
`RAGCoreClient` (sync) needs this rather than `httpx.ASGITransport`:
ASGITransport only implements httpx's *async* transport interface, so a
sync `httpx.Client` can't use it at all (`AsyncRAGCoreClient` tests use
ASGITransport directly instead, which is the supported combination).
"""
# Hand uvicorn the already-bound socket itself, rather than closing it
# and passing the port number for uvicorn to rebind: closing and
# rebinding is a real race (another process, or even this same
# process's next bind, can win the port first), which is what
# produced Windows `STARTUP_FAILURE`s here in practice.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(128)
port = sock.getsockname()[1]
config = uvicorn.Config(app, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=_serve_forever, args=(server, sock), daemon=True)
thread.start()
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
time.sleep(0.01)
try:
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
thread.join(timeout=10)
with contextlib.suppress(OSError):
sock.close()
__all__ = [
"AnswerFixture",
"ContextFixture",
"SearchFixture",
"build_answer_app",
"build_context_app",
"build_search_app",
"run_app",
]
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from ragcore_sdk import AnswerStreamEventType, RAGCoreClient
from conftest import build_answer_app, run_app
@contextmanager
def _client(app: object) -> Iterator[RAGCoreClient]:
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
yield client
def test_answer_sync_returns_typed_response() -> None:
fixture = build_answer_app()
with _client(fixture.app) as client:
response = client.answer(query="refund window", requested_space_ids=[fixture.space_id])
assert response.answer
assert response.citations
assert all(claim.citation_ids for claim in response.claims)
def test_answer_stream_emits_metadata_then_done_in_order() -> None:
fixture = build_answer_app()
with _client(fixture.app) as client:
events = list(
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
)
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.DONE,
]
done_answer = events[1].as_done_answer()
assert done_answer is not None
assert done_answer.citations
assert "refund" in done_answer.answer.lower()
assert events[0].as_done_answer() is None
def test_answer_stream_reports_terminal_error_event_on_invalid_generation() -> None:
fixture = build_answer_app(generator_responses=["not json", "still not json"])
with _client(fixture.app) as client:
events = list(
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
)
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.ERROR,
]
assert events[1].data["code"] == "validation_retries_exhausted"
@@ -0,0 +1,39 @@
from __future__ import annotations
import httpx
from ragcore_sdk import AnswerStreamEventType, AsyncRAGCoreClient
from conftest import build_answer_app, build_search_app
def _async_client(app: object) -> AsyncRAGCoreClient:
return AsyncRAGCoreClient(
base_url="http://testserver",
token="sdk-test-token",
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
)
async def test_async_search_returns_typed_results() -> None:
fixture = build_search_app()
async with _async_client(fixture.app) as client:
response = await client.search(query="refund", requested_space_ids=[fixture.space_id])
assert response.results[0].citation.excerpt_sha256
async def test_async_answer_stream_emits_metadata_then_done() -> None:
fixture = build_answer_app()
async with _async_client(fixture.app) as client:
events = [
event
async for event in client.answer_stream(
query="refund window", requested_space_ids=[fixture.space_id]
)
]
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.DONE,
]
assert events[1].as_done_answer() is not None
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
from conftest import build_context_app, run_app
@contextmanager
def _client(app: object) -> Iterator[RAGCoreClient]:
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
yield client
def test_context_returns_typed_blocks_and_citations() -> None:
fixture = build_context_app()
with _client(fixture.app) as client:
response = client.context(query="refund", requested_space_ids=[fixture.space_id])
assert response.answerability.value in {
"answerable",
"partially_answerable",
"not_answerable",
"conflicting_evidence",
}
assert response.token_count >= 0
if response.blocks:
assert response.blocks[0].citation_ids
assert response.citations
def test_context_token_budget_below_minimum_raises_typed_problem() -> None:
fixture = build_context_app()
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
client.context(
query="refund",
requested_space_ids=[fixture.space_id],
max_context_tokens=1,
)
problem = excinfo.value.problem
assert problem.status == 422
assert problem.code == "VALIDATION_ERROR"
+108
View File
@@ -0,0 +1,108 @@
"""Retry behavior: only ever on connection failures or a `503`, never on a
`4xx`, and never inside a started SSE stream. See `ragcore_sdk.client`'s
module docstring for the reasoning.
"""
from __future__ import annotations
import httpx
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient, RAGCoreConnectionError
from conftest import build_search_app, run_app
class FlakyTransport(httpx.BaseTransport):
"""Returns a synthetic 503 for the first `fail_times` requests, then
delegates to a real HTTP transport pointed at the real running server.
Counts every request it sees.
"""
def __init__(self, *, fail_times: int) -> None:
self._inner = httpx.HTTPTransport()
self._fail_times = fail_times
self.call_count = 0
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.call_count += 1
if self.call_count <= self._fail_times:
return httpx.Response(
503,
json={
"type": "https://ragcore.example.test/problems/search_unavailable",
"title": "Search unavailable",
"status": 503,
"code": "SEARCH_UNAVAILABLE",
"detail": "synthetic failure for retry test",
"trace_id": "test-trace",
},
)
return self._inner.handle_request(request)
class AlwaysDownTransport(httpx.BaseTransport):
def __init__(self) -> None:
self.call_count = 0
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.call_count += 1
raise httpx.ConnectError("synthetic connection failure", request=request)
def test_retries_on_503_then_succeeds() -> None:
fixture = build_search_app()
with run_app(fixture.app) as base_url:
transport = FlakyTransport(fail_times=2)
client = RAGCoreClient(
base_url=base_url,
token="sdk-test-token",
transport=transport,
max_retries=3,
)
try:
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
finally:
client.close()
assert transport.call_count == 3
assert response.results
def test_does_not_retry_a_client_error() -> None:
fixture = build_search_app()
with run_app(fixture.app) as base_url:
# fail_times=0: every request goes straight through to the real app,
# so this only counts calls -- the 422 below comes from the app's
# own request validation, not from the transport.
counting = FlakyTransport(fail_times=0)
client = RAGCoreClient(
base_url=base_url,
token="sdk-test-token",
transport=counting,
max_retries=3,
)
try:
with pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
finally:
client.close()
assert excinfo.value.problem.status == 422
assert counting.call_count == 1
def test_connection_failure_exhausts_retries_then_raises() -> None:
transport = AlwaysDownTransport()
client = RAGCoreClient(
base_url="http://127.0.0.1:1",
token="sdk-test-token",
transport=transport,
max_retries=2,
)
try:
with pytest.raises(RAGCoreConnectionError):
client.search(query="refund")
finally:
client.close()
assert transport.call_count == 3
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from uuid import uuid4
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
from conftest import build_search_app, run_app
@contextmanager
def _client(app: object, *, token: str = "sdk-test-token") -> Iterator[RAGCoreClient]: # noqa: S107
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token=token) as client: # type: ignore[arg-type]
yield client
def test_search_returns_typed_results() -> None:
fixture = build_search_app()
with _client(fixture.app) as client:
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
assert response.effective_space_ids == [fixture.space_id]
assert response.results[0].citation.excerpt_sha256
assert response.results[0].rank == 1
assert response.degraded is False
def test_search_out_of_scope_space_narrows_silently_to_empty() -> None:
fixture = build_search_app()
forbidden_space = uuid4()
with _client(fixture.app) as client:
response = client.search(query="refund", requested_space_ids=[forbidden_space])
assert response.effective_space_ids == []
assert response.results == []
def test_search_validation_error_raises_typed_problem() -> None:
fixture = build_search_app()
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
problem = excinfo.value.problem
assert problem.status == 422
assert problem.code == "VALIDATION_ERROR"
assert problem.trace_id
assert problem.errors
def test_bearer_token_never_appears_in_client_repr_or_error_message() -> None:
fixture = build_search_app()
secret_token = "super-secret-bearer-value-should-never-leak"
with _client(fixture.app, token=secret_token) as client:
assert secret_token not in repr(client)
with pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
assert secret_token not in str(excinfo.value)
problem = excinfo.value.problem
for field in problem.__slots__:
assert secret_token not in repr(getattr(problem, field))
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { asDoneAnswer, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
import { FIXTURE_TRIGGER_ANSWER_ERROR, startFixtureServer, type FixtureServer } from "./fixtureServer.js";
describe("answers", () => {
let server: FixtureServer;
let client: RAGCoreClient;
beforeEach(async () => {
server = await startFixtureServer();
client = new RAGCoreClient(server.url, "sdk-test-token");
});
afterEach(async () => {
await server.close();
});
it("returns a typed response for a non-streaming answer", async () => {
const spaceId = randomUUID();
const response = await client.answer("refund window", { requestedSpaceIds: [spaceId] });
expect(response.answer).toBeTruthy();
expect(response.citations.length).toBeGreaterThan(0);
expect(response.claims.every((claim) => claim.citation_ids.length > 0)).toBe(true);
});
it("streams retrieval_metadata then done in order", async () => {
const spaceId = randomUUID();
const events = [];
for await (const event of client.answerStream("refund window", { requestedSpaceIds: [spaceId] })) {
events.push(event);
}
expect(events.map((event) => event.type)).toEqual(["retrieval_metadata", "done"]);
const doneAnswer = asDoneAnswer(events[1]!);
expect(doneAnswer).not.toBeNull();
expect(doneAnswer?.citations.length).toBeGreaterThan(0);
expect(asDoneAnswer(events[0]!)).toBeNull();
});
it("streams a terminal error event on a generation failure", async () => {
const spaceId = randomUUID();
const events = [];
for await (const event of client.answerStream(`refund window ${FIXTURE_TRIGGER_ANSWER_ERROR}`, {
requestedSpaceIds: [spaceId],
})) {
events.push(event);
}
expect(events.map((event) => event.type)).toEqual(["retrieval_metadata", "error"]);
expect(events[1]?.data["code"]).toBe("validation_retries_exhausted");
});
});
@@ -0,0 +1,44 @@
import { randomUUID } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RAGCoreAPIError, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
import { startFixtureServer, type FixtureServer } from "./fixtureServer.js";
describe("context", () => {
let server: FixtureServer;
let client: RAGCoreClient;
beforeEach(async () => {
server = await startFixtureServer();
client = new RAGCoreClient(server.url, "sdk-test-token");
});
afterEach(async () => {
await server.close();
});
it("returns typed blocks and citations", async () => {
const spaceId = randomUUID();
const response = await client.context("refund", { requestedSpaceIds: [spaceId] });
expect(["answerable", "partially_answerable", "not_answerable", "conflicting_evidence"]).toContain(
response.answerability
);
expect(response.token_count).toBeGreaterThanOrEqual(0);
if (response.blocks.length > 0) {
expect(response.blocks[0]?.citation_ids.length).toBeGreaterThan(0);
expect(response.citations.length).toBeGreaterThan(0);
}
});
it("raises a typed problem on a validation failure", async () => {
let caught: unknown;
try {
await client.context(" ");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RAGCoreAPIError);
expect((caught as RAGCoreAPIError).problem.status).toBe(422);
});
});
@@ -0,0 +1,284 @@
/**
* A minimal, real local HTTP server standing in for RAGCore's own
* `/v1/{search,context,answers}` routes, used only to test the TypeScript
* SDK's own HTTP layer (request shape, response typing, SSE parsing,
* Problem Details mapping, retries) -- not to re-prove RAGCore's
* authorization/retrieval business logic, which the real Python contract
* tests (`tests/contract/api/**`) and the Python SDK's own real-backend
* tests (`tests/contract/sdk/python/**`, M7-01) already cover against the
* actual FastAPI app.
*
* Response shapes here are copied verbatim from the real Pydantic schemas
* (`ragcore.api.v1.{search,context,answers}.schemas`) and the real SSE wire
* format (`ragcore.api.v1.answers.routes._sse_body`:
* `event: <type>\ndata: <json>\n\n`), including the real (fixed) RFC7807
* Problem Details shape with a populated `errors` array on a 422.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
export interface FixtureServerOptions {
/** Return a synthetic 503 for this many requests before serving normally. */
failFirstNRequests?: number;
}
export interface FixtureServer {
url: string;
requestCount: () => number;
close: () => Promise<void>;
}
const FORBIDDEN_SPACE_ID = "00000000-0000-0000-0000-000000000fff";
const TRIGGER_ANSWER_ERROR = "TRIGGER_ERROR";
function readBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
if (raw.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown, contentType = "application/json"): void {
const payload = JSON.stringify(body);
res.writeHead(status, { "content-type": contentType });
res.end(payload);
}
function sendValidationProblem(res: ServerResponse, field: string, message: string): void {
sendJson(
res,
422,
{
type: "https://ragcore.example.test/problems/validation_error",
title: "Request validation failed",
status: 422,
code: "VALIDATION_ERROR",
detail: "The request did not satisfy the API contract.",
trace_id: randomUUID(),
errors: [{ field, message }],
},
"application/problem+json"
);
}
function fixtureCitation(id: string): Record<string, unknown> {
return {
id,
document_id: randomUUID(),
document_version_id: randomUUID(),
title: "Refund policy",
section: "Refunds",
page_start: 4,
page_end: 4,
line_start: null,
line_end: null,
excerpt: "The refund window is thirty days from the original purchase date.",
excerpt_sha256: "a".repeat(64),
source_uri: "https://example.test/policy.md",
};
}
export function startFixtureServer(options: FixtureServerOptions = {}): Promise<FixtureServer> {
let requestCount = 0;
let failuresRemaining = options.failFirstNRequests ?? 0;
const server: Server = createServer((req, res) => {
void (async () => {
requestCount += 1;
if (failuresRemaining > 0) {
failuresRemaining -= 1;
sendJson(
res,
503,
{
type: "https://ragcore.example.test/problems/search_unavailable",
title: "Search unavailable",
status: 503,
code: "SEARCH_UNAVAILABLE",
detail: "synthetic failure for retry test",
trace_id: randomUUID(),
errors: [],
},
"application/problem+json"
);
return;
}
const auth = req.headers["authorization"];
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) {
sendJson(
res,
401,
{
type: "https://ragcore.example.test/problems/authentication_required",
title: "Authentication required",
status: 401,
code: "AUTHENTICATION_REQUIRED",
detail: "Authentication is required.",
trace_id: randomUUID(),
errors: [],
},
"application/problem+json"
);
return;
}
const body = (await readBody(req)) as Record<string, unknown>;
const query = typeof body["query"] === "string" ? body["query"] : "";
if (query.trim() === "") {
sendValidationProblem(res, "body.query", "String should match pattern '.*\\S.*'");
return;
}
const requestedSpaceIds = Array.isArray(body["requested_space_ids"])
? (body["requested_space_ids"] as string[])
: [];
const forbidden = requestedSpaceIds.includes(FORBIDDEN_SPACE_ID);
const effectiveSpaceIds = forbidden ? [] : requestedSpaceIds.length > 0 ? requestedSpaceIds : [randomUUID()];
const retrievalRunId = randomUUID();
const citationId = randomUUID();
if (req.url === "/v1/search") {
sendJson(res, 200, {
retrieval_run_id: retrievalRunId,
effective_space_ids: effectiveSpaceIds,
results:
effectiveSpaceIds.length === 0
? []
: [
{
chunk_id: randomUUID(),
parent_chunk_id: null,
text: "The refund window is thirty days from the original purchase date.",
rank: 1,
scores: { dense: 0.87, sparse: 0.41, fused: 1 / 61, rerank: null },
citation: fixtureCitation(citationId),
},
],
degraded: false,
degradation_codes: [],
timings_ms: { dense: 12.5 },
});
return;
}
if (req.url === "/v1/context") {
sendJson(res, 200, {
retrieval_run_id: retrievalRunId,
answerability: effectiveSpaceIds.length === 0 ? "not_answerable" : "partially_answerable",
answerability_reasons: [],
blocks:
effectiveSpaceIds.length === 0
? []
: [{ text: "Refund evidence.", citation_ids: [citationId], token_count: 4 }],
citations: effectiveSpaceIds.length === 0 ? [] : [fixtureCitation(citationId)],
token_count: effectiveSpaceIds.length === 0 ? 0 : 4,
});
return;
}
if (req.url === "/v1/answers") {
const stream = body["stream"] === true;
const answerable = effectiveSpaceIds.length > 0;
const errored = query.includes(TRIGGER_ANSWER_ERROR);
if (!stream) {
if (errored) {
sendJson(
res,
503,
{
type: "https://ragcore.example.test/problems/generation_unavailable",
title: "Answers unavailable",
status: 503,
code: "GENERATION_UNAVAILABLE",
detail: "The answer service is temporarily unavailable.",
trace_id: randomUUID(),
errors: [],
},
"application/problem+json"
);
return;
}
sendJson(res, 200, {
answer_id: randomUUID(),
retrieval_run_id: retrievalRunId,
answerability: answerable ? "partially_answerable" : "not_answerable",
answer: answerable ? "The refund window is thirty days from purchase." : "",
citations: answerable ? [fixtureCitation(citationId)] : [],
claims: answerable ? [{ text: "Thirty days.", citation_ids: [citationId] }] : [],
degraded: false,
});
return;
}
res.writeHead(200, { "content-type": "text/event-stream" });
const metadata = {
retrieval_run_id: retrievalRunId,
effective_space_ids: effectiveSpaceIds,
answerability: answerable ? "partially_answerable" : "not_answerable",
answerability_reasons: [],
};
res.write(`event: retrieval_metadata\ndata: ${JSON.stringify(metadata)}\n\n`);
if (errored) {
res.write(`event: error\ndata: ${JSON.stringify({ code: "validation_retries_exhausted" })}\n\n`);
} else {
const done = {
answer_id: randomUUID(),
retrieval_run_id: retrievalRunId,
answerability: answerable ? "partially_answerable" : "not_answerable",
answer: answerable ? "The refund window is thirty days from purchase." : "",
citations: answerable ? [fixtureCitation(citationId)] : [],
claims: answerable ? [{ text: "Thirty days.", citation_ids: [citationId] }] : [],
degraded: false,
};
res.write(`event: done\ndata: ${JSON.stringify(done)}\n\n`);
}
res.end();
return;
}
sendJson(res, 404, {
type: "about:blank",
title: "Not Found",
status: 404,
code: "NOT_FOUND",
detail: null,
trace_id: randomUUID(),
errors: [],
});
})();
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address === null || typeof address === "string") {
reject(new Error("failed to bind fixture server"));
return;
}
resolve({
url: `http://127.0.0.1:${address.port}`,
requestCount: () => requestCount,
close: () => new Promise((res) => server.close(() => res())),
});
});
});
}
export const FIXTURE_FORBIDDEN_SPACE_ID = FORBIDDEN_SPACE_ID;
export const FIXTURE_TRIGGER_ANSWER_ERROR = TRIGGER_ANSWER_ERROR;
@@ -0,0 +1,53 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { RAGCoreAPIError, RAGCoreClient, RAGCoreConnectionError } from "../../../../sdk/typescript/src/index.js";
import { startFixtureServer } from "./fixtureServer.js";
describe("retries", () => {
it("retries a 503 twice then succeeds", async () => {
const server = await startFixtureServer({ failFirstNRequests: 2 });
try {
const client = new RAGCoreClient(server.url, "sdk-test-token", { maxRetries: 3 });
const response = await client.search("refund", { requestedSpaceIds: [randomUUID()] });
expect(server.requestCount()).toBe(3);
expect(response.results.length).toBeGreaterThan(0);
} finally {
await server.close();
}
});
it("never retries a real 4xx client error", async () => {
const server = await startFixtureServer();
try {
const client = new RAGCoreClient(server.url, "sdk-test-token", { maxRetries: 3 });
let caught: unknown;
try {
await client.search(" ");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RAGCoreAPIError);
expect((caught as RAGCoreAPIError).problem.status).toBe(422);
expect(server.requestCount()).toBe(1);
} finally {
await server.close();
}
});
it("exhausts retries on a connection failure and raises RAGCoreConnectionError", async () => {
// Port 1 is a privileged port nothing is listening on in this sandbox --
// fetch fails at the connection stage every time, deterministically.
const client = new RAGCoreClient("http://127.0.0.1:1", "sdk-test-token", { maxRetries: 2, timeoutMs: 2000 });
let caught: unknown;
try {
await client.search("refund");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RAGCoreConnectionError);
});
});
@@ -0,0 +1,67 @@
import { randomUUID } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RAGCoreAPIError, RAGCoreClient } from "../../../../sdk/typescript/src/index.js";
import { FIXTURE_FORBIDDEN_SPACE_ID, startFixtureServer, type FixtureServer } from "./fixtureServer.js";
describe("search", () => {
let server: FixtureServer;
let client: RAGCoreClient;
beforeEach(async () => {
server = await startFixtureServer();
client = new RAGCoreClient(server.url, "sdk-test-token");
});
afterEach(async () => {
await server.close();
});
it("returns typed results", async () => {
const spaceId = randomUUID();
const response = await client.search("refund", { requestedSpaceIds: [spaceId] });
expect(response.effective_space_ids).toEqual([spaceId]);
expect(response.results[0]?.citation.excerpt_sha256).toBeTruthy();
expect(response.results[0]?.rank).toBe(1);
expect(response.degraded).toBe(false);
});
it("narrows a forbidden space to an empty result silently", async () => {
const response = await client.search("refund", { requestedSpaceIds: [FIXTURE_FORBIDDEN_SPACE_ID] });
expect(response.effective_space_ids).toEqual([]);
expect(response.results).toEqual([]);
});
it("raises a typed problem with populated errors on a validation failure", async () => {
let caught: unknown;
try {
await client.search(" ");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(RAGCoreAPIError);
const problem = (caught as RAGCoreAPIError).problem;
expect(problem.status).toBe(422);
expect(problem.code).toBe("VALIDATION_ERROR");
expect(problem.trace_id).toBeTruthy();
expect(problem.errors.length).toBeGreaterThan(0);
});
it("never leaks the bearer token through the client or a thrown error", async () => {
const secretToken = "super-secret-bearer-value-should-never-leak";
const secretClient = new RAGCoreClient(server.url, secretToken);
expect(String(secretClient)).not.toContain(secretToken);
expect(JSON.stringify(secretClient)).not.toContain(secretToken);
try {
await secretClient.search(" ");
throw new Error("expected search to reject");
} catch (error) {
expect(String(error)).not.toContain(secretToken);
expect(JSON.stringify((error as RAGCoreAPIError).problem)).not.toContain(secretToken);
}
});
});