generated from coulomb/repo-seed
Implemented profile-scoped CMIS Browser Binding routes
This commit is contained in:
@@ -65,3 +65,26 @@ The mapper returns `None` for assets or relationships that the access-point
|
||||
profile must not expose. It does not fetch from repositories directly; callers
|
||||
provide the asset, representations, versions, metadata records, and
|
||||
relationships they have already authorized or loaded.
|
||||
|
||||
## Browser Binding MVP Slice
|
||||
|
||||
The service exposes profile-scoped Browser Binding MVP routes:
|
||||
|
||||
- `GET /cmis`
|
||||
- `GET /cmis/{access_point_id}/browser`
|
||||
- `GET /cmis/{access_point_id}/browser/types`
|
||||
- `GET /cmis/{access_point_id}/browser/children`
|
||||
- `GET /cmis/{access_point_id}/browser/object/{object_id}`
|
||||
- `GET /cmis/{access_point_id}/browser/content/{object_id}`
|
||||
- `GET /cmis/{access_point_id}/browser/query`
|
||||
- `GET /cmis/{access_point_id}/browser/relationships`
|
||||
- `GET /cmis/{access_point_id}/browser/changes`
|
||||
|
||||
The MVP supports repository info, type definitions, synthetic root children,
|
||||
object reads, content stream descriptors, a constrained document query subset,
|
||||
relationship objects, and audit-backed change entries. Unsupported query
|
||||
grammar returns structured diagnostics.
|
||||
|
||||
Route-level tests are present but skip when the optional FastAPI/httpx service
|
||||
dependencies are not installed. Runtime-level Browser Binding tests cover the
|
||||
same behavior in the default Python test suite.
|
||||
|
||||
@@ -19,6 +19,10 @@ from kontextual_engine.core import (
|
||||
AuditEvent,
|
||||
AuditOutcome,
|
||||
Classification,
|
||||
CMISAccessPoint,
|
||||
CMISAccessProfile,
|
||||
CMISAction,
|
||||
CMISDomainMapper,
|
||||
ContextEntity,
|
||||
ContextEntityType,
|
||||
IngestionIdentityPolicy,
|
||||
@@ -316,6 +320,203 @@ class ServiceRuntime:
|
||||
"openapi_version": OPENAPI_VERSION,
|
||||
}
|
||||
|
||||
def cmis_access_points(self) -> dict[str, Any]:
|
||||
access_points = [_cmis_access_point(profile) for profile in _cmis_profiles()]
|
||||
return {"items": [access_point.to_dict() for access_point in access_points], "count": len(access_points)}
|
||||
|
||||
def cmis_repository_info(self, access_point_id: str) -> dict[str, Any]:
|
||||
return self._cmis_mapper(access_point_id).repository_info()
|
||||
|
||||
def cmis_type_definitions(self, access_point_id: str) -> dict[str, Any]:
|
||||
definitions = self._cmis_mapper(access_point_id).type_definitions()
|
||||
return {"items": definitions, "count": len(definitions)}
|
||||
|
||||
def cmis_children(
|
||||
self,
|
||||
access_point_id: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
folder_id: str | None = None,
|
||||
skip_count: int = 0,
|
||||
max_items: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_CHILDREN, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getChildren")
|
||||
projections = [
|
||||
projection.to_dict()
|
||||
for asset in self.repository.list_assets()
|
||||
if (
|
||||
projection := mapper.map_asset(
|
||||
asset,
|
||||
context,
|
||||
representations=self.repository.list_representations(asset_id=asset.id),
|
||||
versions=self.repository.list_versions(asset.id),
|
||||
relationship_ids=[
|
||||
f"cmis:relationship:{relationship.relationship_id}"
|
||||
for relationship in self.repository.list_relationships(source_id=asset.id)
|
||||
],
|
||||
metadata_records=self.repository.list_metadata_records(asset.id),
|
||||
)
|
||||
)
|
||||
]
|
||||
paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
|
||||
return {
|
||||
"folder_id": folder_id or mapper.access_point.root_folder_id,
|
||||
"objects": paged,
|
||||
"num_items": len(paged),
|
||||
"has_more_items": len(projections) > max(skip_count, 0) + len(paged),
|
||||
"total_num_items": len(projections),
|
||||
}
|
||||
|
||||
def cmis_object(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getObject")
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
projection = mapper.map_asset(
|
||||
asset,
|
||||
context,
|
||||
representations=self.repository.list_representations(asset_id=asset.id),
|
||||
versions=self.repository.list_versions(asset.id),
|
||||
relationship_ids=[
|
||||
f"cmis:relationship:{relationship.relationship_id}"
|
||||
for relationship in self.repository.list_relationships(source_id=asset.id)
|
||||
],
|
||||
metadata_records=self.repository.list_metadata_records(asset.id),
|
||||
)
|
||||
if projection is None:
|
||||
raise NotFoundError(
|
||||
"CMIS object not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return projection.to_dict()
|
||||
|
||||
def cmis_content_stream(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_CONTENT_STREAM, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getContentStream")
|
||||
object_projection = self.cmis_object(access_point_id, object_id, context)
|
||||
content_stream = object_projection.get("content_stream")
|
||||
if not content_stream:
|
||||
raise NotFoundError(
|
||||
"CMIS content stream not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return content_stream
|
||||
|
||||
def cmis_query(
|
||||
self,
|
||||
access_point_id: str,
|
||||
query: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
skip_count: int = 0,
|
||||
max_items: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.QUERY, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "query")
|
||||
normalized = query.strip().lower()
|
||||
if normalized not in {"select * from cmis:document", "select * from kontextual:document"}:
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS query subset",
|
||||
details={
|
||||
"query": query,
|
||||
"supported": ["SELECT * FROM cmis:document", "SELECT * FROM kontextual:document"],
|
||||
},
|
||||
)
|
||||
children = self.cmis_children(
|
||||
access_point_id,
|
||||
context,
|
||||
skip_count=skip_count,
|
||||
max_items=max_items,
|
||||
)
|
||||
return {
|
||||
"query": query,
|
||||
"results": children["objects"],
|
||||
"num_items": children["num_items"],
|
||||
"has_more_items": children["has_more_items"],
|
||||
"total_num_items": children["total_num_items"],
|
||||
}
|
||||
|
||||
def cmis_relationships(
|
||||
self,
|
||||
access_point_id: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
object_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_RELATIONSHIPS, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getRelationships")
|
||||
source_id = _cmis_asset_id(object_id) if object_id else None
|
||||
projections = [
|
||||
projection.to_dict()
|
||||
for relationship in self.repository.list_relationships(source_id=source_id)
|
||||
if (projection := mapper.map_relationship(relationship, context))
|
||||
]
|
||||
return {"items": projections, "count": len(projections)}
|
||||
|
||||
def cmis_change_log(
|
||||
self,
|
||||
access_point_id: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
skip_count: int = 0,
|
||||
max_items: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_CHANGE_LOG, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getContentChanges")
|
||||
events = self.repository.list_audit_events()
|
||||
changes = [
|
||||
{
|
||||
"change_id": event.event_id,
|
||||
"change_type": _cmis_change_type(event.operation),
|
||||
"object_id": event.target.replace("asset:", "cmis:asset:", 1),
|
||||
"change_time": event.occurred_at,
|
||||
"actor_id": event.actor_id,
|
||||
"correlation_id": event.correlation_id,
|
||||
}
|
||||
for event in events
|
||||
if event.target.startswith("asset:")
|
||||
]
|
||||
paged = changes[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
|
||||
return {
|
||||
"change_log_token": changes[-1]["change_id"] if changes else None,
|
||||
"changes": paged,
|
||||
"num_items": len(paged),
|
||||
"has_more_items": len(changes) > max(skip_count, 0) + len(paged),
|
||||
"total_num_items": len(changes),
|
||||
}
|
||||
|
||||
def _cmis_mapper(self, access_point_id: str) -> CMISDomainMapper:
|
||||
for profile in _cmis_profiles():
|
||||
if profile.name == access_point_id:
|
||||
return CMISDomainMapper(_cmis_access_point(profile))
|
||||
raise NotFoundError(
|
||||
"CMIS access point not found",
|
||||
details={"access_point_id": access_point_id, "available": [profile.name for profile in _cmis_profiles()]},
|
||||
)
|
||||
|
||||
def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]:
|
||||
classification = Classification.from_dict(payload["classification"])
|
||||
result = self.asset_service().create_asset(
|
||||
@@ -1718,6 +1919,91 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
) -> dict[str, Any]:
|
||||
return context.to_dict()
|
||||
|
||||
@app.get("/cmis", tags=["cmis"])
|
||||
def cmis_access_points() -> dict[str, Any]:
|
||||
return response(runtime.cmis_access_points)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
|
||||
def cmis_repository_info(access_point_id: str) -> dict[str, Any]:
|
||||
return response(runtime.cmis_repository_info, access_point_id)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
|
||||
def cmis_types(access_point_id: str) -> dict[str, Any]:
|
||||
return response(runtime.cmis_type_definitions, access_point_id)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/children", tags=["cmis"])
|
||||
def cmis_children(
|
||||
access_point_id: str,
|
||||
folder_id: str | None = Query(None),
|
||||
skip_count: int = Query(0),
|
||||
max_items: int = Query(100),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(
|
||||
runtime.cmis_children,
|
||||
access_point_id,
|
||||
context,
|
||||
folder_id=folder_id,
|
||||
skip_count=skip_count,
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/object/{object_id:path}", tags=["cmis"])
|
||||
def cmis_object(
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_object, access_point_id, object_id, context)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/content/{object_id:path}", tags=["cmis"])
|
||||
def cmis_content_stream(
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_content_stream, access_point_id, object_id, context)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/query", tags=["cmis"])
|
||||
def cmis_query(
|
||||
access_point_id: str,
|
||||
q: str = Query("SELECT * FROM cmis:document"),
|
||||
skip_count: int = Query(0),
|
||||
max_items: int = Query(100),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(
|
||||
runtime.cmis_query,
|
||||
access_point_id,
|
||||
q,
|
||||
context,
|
||||
skip_count=skip_count,
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/relationships", tags=["cmis"])
|
||||
def cmis_relationships(
|
||||
access_point_id: str,
|
||||
object_id: str | None = Query(None),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_relationships, access_point_id, context, object_id=object_id)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/changes", tags=["cmis"])
|
||||
def cmis_changes(
|
||||
access_point_id: str,
|
||||
skip_count: int = Query(0),
|
||||
max_items: int = Query(100),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(
|
||||
runtime.cmis_change_log,
|
||||
access_point_id,
|
||||
context,
|
||||
skip_count=skip_count,
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
@app.post(f"{prefix}/assets", tags=["assets"])
|
||||
def create_asset(
|
||||
payload: dict[str, Any],
|
||||
@@ -2105,6 +2391,57 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
return app
|
||||
|
||||
|
||||
def _cmis_profiles() -> tuple[CMISAccessProfile, ...]:
|
||||
return (
|
||||
CMISAccessProfile.readonly_browser(),
|
||||
CMISAccessProfile.governed_authoring(),
|
||||
CMISAccessProfile.admin_export(),
|
||||
CMISAccessProfile.compat_tck(),
|
||||
)
|
||||
|
||||
|
||||
def _cmis_access_point(profile: CMISAccessProfile) -> CMISAccessPoint:
|
||||
return CMISAccessPoint(
|
||||
access_point_id=profile.name,
|
||||
repository_id=f"kontextual-{profile.name}",
|
||||
profile=profile,
|
||||
base_path=f"/cmis/{profile.name}/browser",
|
||||
metadata={"repository_name": f"Kontextual Engine {profile.name}"},
|
||||
)
|
||||
|
||||
|
||||
def _cmis_asset_id(object_id: str | None) -> str:
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"field": "object_id"})
|
||||
normalized = object_id.strip("/")
|
||||
if normalized.startswith("cmis:asset:"):
|
||||
return normalized.removeprefix("cmis:asset:")
|
||||
if normalized.startswith("asset:"):
|
||||
return normalized.removeprefix("asset:")
|
||||
return normalized
|
||||
|
||||
|
||||
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
|
||||
return AuthorizationError(
|
||||
"CMIS operation denied by access-point profile",
|
||||
details={
|
||||
"operation": operation,
|
||||
"policy_decision": decision.to_dict(),
|
||||
"code": "cmis.permission_denied",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _cmis_change_type(operation: str) -> str:
|
||||
if operation.endswith(".create") or operation == "asset.create":
|
||||
return "created"
|
||||
if "delete" in operation:
|
||||
return "deleted"
|
||||
if "metadata" in operation or "content" in operation or "lifecycle" in operation:
|
||||
return "updated"
|
||||
return "security"
|
||||
|
||||
|
||||
def _age_seconds(start: str, end: str) -> float:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
|
||||
164
tests/cmis/test_cmis_browser_binding_api.py
Normal file
164
tests/cmis/test_cmis_browser_binding_api.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from kontextual_engine import (
|
||||
AssetRepresentation,
|
||||
Classification,
|
||||
RepresentationKind,
|
||||
ServiceRuntime,
|
||||
Sensitivity,
|
||||
create_app,
|
||||
)
|
||||
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.cmis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmis_client():
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("httpx")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
runtime = ServiceRuntime(repository=InMemoryAssetRegistryRepository())
|
||||
context = runtime.operation_context(actor_id="cmis-test", correlation_id="corr-cmis-api")
|
||||
runtime.asset_service().create_asset(
|
||||
"Source",
|
||||
Classification(
|
||||
asset_type="document",
|
||||
sensitivity=Sensitivity.INTERNAL,
|
||||
owner="Platform Knowledge",
|
||||
topics=("cmis",),
|
||||
),
|
||||
context,
|
||||
asset_id="asset-source",
|
||||
representations=[
|
||||
AssetRepresentation.from_content(
|
||||
"asset-source",
|
||||
RepresentationKind.SOURCE,
|
||||
"text/markdown",
|
||||
"# Source\n\nCMIS Browser Binding test fixture.",
|
||||
storage_ref="memory://asset-source/source",
|
||||
)
|
||||
],
|
||||
)
|
||||
runtime.create_asset(
|
||||
{
|
||||
"asset_id": "asset-public",
|
||||
"title": "Public Target",
|
||||
"classification": {"asset_type": "document", "sensitivity": "public"},
|
||||
},
|
||||
context,
|
||||
)
|
||||
runtime.create_asset(
|
||||
{
|
||||
"asset_id": "asset-confidential",
|
||||
"title": "Confidential Target",
|
||||
"classification": {"asset_type": "document", "sensitivity": "confidential"},
|
||||
},
|
||||
context,
|
||||
)
|
||||
runtime.create_relationship(
|
||||
{
|
||||
"source_asset_id": "asset-source",
|
||||
"target_id": "asset-public",
|
||||
"predicate": "references",
|
||||
"target_kind": "asset",
|
||||
"confidence": 1.0,
|
||||
},
|
||||
context,
|
||||
)
|
||||
with TestClient(create_app(runtime)) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> None:
|
||||
paths = cmis_client.get("/openapi.json").json()["paths"]
|
||||
|
||||
assert "/cmis" in paths
|
||||
assert "/cmis/{access_point_id}/browser" in paths
|
||||
assert "/cmis/{access_point_id}/browser/types" in paths
|
||||
assert "/cmis/{access_point_id}/browser/children" in paths
|
||||
assert "/cmis/{access_point_id}/browser/object/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/content/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/query" in paths
|
||||
assert "/cmis/{access_point_id}/browser/relationships" in paths
|
||||
assert "/cmis/{access_point_id}/browser/changes" in paths
|
||||
|
||||
|
||||
def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
|
||||
access_points = cmis_client.get("/cmis").json()
|
||||
repository = cmis_client.get("/cmis/readonly-browser/browser").json()
|
||||
types = cmis_client.get("/cmis/readonly-browser/browser/types").json()
|
||||
|
||||
assert access_points["count"] == 4
|
||||
assert repository["repository_id"] == "kontextual-readonly-browser"
|
||||
assert repository["cmis_version_supported"] == "1.1"
|
||||
assert repository["capabilities"]["capability_query"] == "metadataonly"
|
||||
assert {item["base_type_id"] for item in types["items"]} >= {
|
||||
"cmis:document",
|
||||
"cmis:folder",
|
||||
"cmis:relationship",
|
||||
}
|
||||
|
||||
|
||||
def test_cmis_readonly_children_object_content_query_relationships_and_changes(cmis_client) -> None:
|
||||
children = cmis_client.get("/cmis/readonly-browser/browser/children").json()
|
||||
object_response = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/object/cmis:asset:asset-source"
|
||||
).json()
|
||||
content = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/content/cmis:asset:asset-source"
|
||||
).json()
|
||||
query = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/query",
|
||||
params={"q": "SELECT * FROM cmis:document"},
|
||||
).json()
|
||||
relationships = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/relationships",
|
||||
params={"object_id": "cmis:asset:asset-source"},
|
||||
).json()
|
||||
changes = cmis_client.get("/cmis/readonly-browser/browser/changes").json()
|
||||
|
||||
child_ids = {item["object_id"] for item in children["objects"]}
|
||||
assert "cmis:asset:asset-source" in child_ids
|
||||
assert "cmis:asset:asset-public" in child_ids
|
||||
assert "cmis:asset:asset-confidential" not in child_ids
|
||||
assert object_response["properties"]["kontextual:assetId"] == "asset-source"
|
||||
assert "get_content_stream" in object_response["allowable_actions"]
|
||||
assert content["mime_type"] == "text/markdown"
|
||||
assert query["total_num_items"] == children["total_num_items"]
|
||||
assert relationships["count"] == 1
|
||||
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-public"
|
||||
assert changes["total_num_items"] >= 3
|
||||
|
||||
|
||||
def test_cmis_profile_gates_visibility_by_access_point(cmis_client) -> None:
|
||||
readonly = cmis_client.get("/cmis/readonly-browser/browser/children").json()
|
||||
admin_denied = cmis_client.get("/cmis/admin-export/browser/children")
|
||||
admin_allowed = cmis_client.get(
|
||||
"/cmis/admin-export/browser/children",
|
||||
headers={"X-Actor-Type": "service_account", "X-Actor-Id": "svc-export"},
|
||||
).json()
|
||||
|
||||
readonly_ids = {item["object_id"] for item in readonly["objects"]}
|
||||
admin_ids = {item["object_id"] for item in admin_allowed["objects"]}
|
||||
|
||||
assert admin_denied.status_code == 403
|
||||
assert "cmis:asset:asset-confidential" not in readonly_ids
|
||||
assert "cmis:asset:asset-confidential" in admin_ids
|
||||
|
||||
|
||||
def test_cmis_query_reports_unsupported_subset_diagnostics(cmis_client) -> None:
|
||||
response = cmis_client.get(
|
||||
"/cmis/readonly-browser/browser/query",
|
||||
params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["details"]["supported"] == [
|
||||
"SELECT * FROM cmis:document",
|
||||
"SELECT * FROM kontextual:document",
|
||||
]
|
||||
120
tests/cmis/test_cmis_runtime_browser_binding.py
Normal file
120
tests/cmis/test_cmis_runtime_browser_binding.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from kontextual_engine import (
|
||||
AssetRepresentation,
|
||||
Classification,
|
||||
RepresentationKind,
|
||||
ServiceRuntime,
|
||||
Sensitivity,
|
||||
)
|
||||
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.cmis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmis_runtime() -> tuple[ServiceRuntime, object]:
|
||||
runtime = ServiceRuntime(repository=InMemoryAssetRegistryRepository())
|
||||
context = runtime.operation_context(actor_id="cmis-runtime", correlation_id="corr-cmis-runtime")
|
||||
runtime.asset_service().create_asset(
|
||||
"Runtime Source",
|
||||
Classification(
|
||||
asset_type="document",
|
||||
sensitivity=Sensitivity.INTERNAL,
|
||||
owner="Platform Knowledge",
|
||||
topics=("cmis",),
|
||||
),
|
||||
context,
|
||||
asset_id="asset-runtime-source",
|
||||
representations=[
|
||||
AssetRepresentation.from_content(
|
||||
"asset-runtime-source",
|
||||
RepresentationKind.SOURCE,
|
||||
"text/markdown",
|
||||
"# Runtime Source\n\nCMIS runtime fixture.",
|
||||
storage_ref="memory://asset-runtime-source/source",
|
||||
)
|
||||
],
|
||||
)
|
||||
runtime.create_asset(
|
||||
{
|
||||
"asset_id": "asset-runtime-public",
|
||||
"title": "Runtime Public",
|
||||
"classification": {"asset_type": "document", "sensitivity": "public"},
|
||||
},
|
||||
context,
|
||||
)
|
||||
runtime.create_asset(
|
||||
{
|
||||
"asset_id": "asset-runtime-confidential",
|
||||
"title": "Runtime Confidential",
|
||||
"classification": {"asset_type": "document", "sensitivity": "confidential"},
|
||||
},
|
||||
context,
|
||||
)
|
||||
runtime.create_relationship(
|
||||
{
|
||||
"source_asset_id": "asset-runtime-source",
|
||||
"target_id": "asset-runtime-public",
|
||||
"predicate": "references",
|
||||
"target_kind": "asset",
|
||||
"confidence": 0.99,
|
||||
},
|
||||
context,
|
||||
)
|
||||
return runtime, context
|
||||
|
||||
|
||||
def test_runtime_cmis_browser_repository_types_children_and_object(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
access_points = runtime.cmis_access_points()
|
||||
repository = runtime.cmis_repository_info("readonly-browser")
|
||||
types = runtime.cmis_type_definitions("readonly-browser")
|
||||
children = runtime.cmis_children("readonly-browser", context)
|
||||
obj = runtime.cmis_object("readonly-browser", "cmis:asset:asset-runtime-source", context)
|
||||
|
||||
assert access_points["count"] == 4
|
||||
assert repository["repository_id"] == "kontextual-readonly-browser"
|
||||
assert repository["capabilities"]["capability_get_descendants"] is True
|
||||
assert {item["base_type_id"] for item in types["items"]} >= {"cmis:document", "cmis:folder"}
|
||||
object_ids = {item["object_id"] for item in children["objects"]}
|
||||
assert "cmis:asset:asset-runtime-source" in object_ids
|
||||
assert "cmis:asset:asset-runtime-public" in object_ids
|
||||
assert "cmis:asset:asset-runtime-confidential" not in object_ids
|
||||
assert obj["properties"]["kontextual:assetId"] == "asset-runtime-source"
|
||||
|
||||
|
||||
def test_runtime_cmis_browser_content_query_relationships_and_changes(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
content = runtime.cmis_content_stream("readonly-browser", "cmis:asset:asset-runtime-source", context)
|
||||
query = runtime.cmis_query("readonly-browser", "SELECT * FROM cmis:document", context)
|
||||
relationships = runtime.cmis_relationships(
|
||||
"readonly-browser",
|
||||
context,
|
||||
object_id="cmis:asset:asset-runtime-source",
|
||||
)
|
||||
changes = runtime.cmis_change_log("readonly-browser", context)
|
||||
|
||||
assert content["mime_type"] in {"text/plain", "text/markdown"}
|
||||
assert query["total_num_items"] == 2
|
||||
assert relationships["count"] == 1
|
||||
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-runtime-public"
|
||||
assert changes["total_num_items"] >= 3
|
||||
|
||||
|
||||
def test_runtime_cmis_browser_rejects_unsupported_query_subset(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
runtime.cmis_query(
|
||||
"readonly-browser",
|
||||
"SELECT * FROM cmis:document JOIN cmis:relationship",
|
||||
context,
|
||||
)
|
||||
|
||||
assert "Unsupported CMIS query subset" in str(exc_info.value)
|
||||
@@ -654,6 +654,9 @@ def test_service_health_readiness_version_and_openapi_contracts(client) -> None:
|
||||
assert "/api/v1/ready" in paths
|
||||
assert "/api/v1/version" in paths
|
||||
assert "/api/v1/context" in paths
|
||||
assert "/cmis" in paths
|
||||
assert "/cmis/{access_point_id}/browser" in paths
|
||||
assert "/cmis/{access_point_id}/browser/children" in paths
|
||||
assert "/api/v1/assets" in paths
|
||||
assert "/api/v1/relationships" in paths
|
||||
assert "/api/v1/audit/events" in paths
|
||||
|
||||
@@ -44,6 +44,8 @@ suite.
|
||||
- `src/kontextual_engine/core/cmis.py`
|
||||
- `tests/cmis/test_cmis_access_profiles.py`
|
||||
- `tests/cmis/test_cmis_domain_mapper.py`
|
||||
- `tests/cmis/test_cmis_runtime_browser_binding.py`
|
||||
- `tests/cmis/test_cmis_browser_binding_api.py`
|
||||
|
||||
## Architecture Constraint
|
||||
|
||||
@@ -91,7 +93,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0012-T003
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b9f5d790-f291-4613-89da-5d47e7887a9e"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user