From 54a26cdb02d911dc6059a881fd9d3523461e96a4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 8 May 2026 12:27:26 +0200 Subject: [PATCH] CMIS Browser Binding serializer layer --- docs/cmis-1-1-capability-scorecard.md | 27 +- ...ncmis-tck-assessment-2026-05-08T063312Z.md | 5 + ...lementation-evidence-2026-05-08T092113Z.md | 164 +++++ src/kontextual_engine/api/app.py | 272 +++++++- src/kontextual_engine/core/cmis.py | 633 ++++++++++++++++++ .../opencmis-tck/tck-result-template.json | 3 +- tests/cmis/opencmis-tck/tck-subset-map.json | 2 +- tests/cmis/test_cmis_browser_binding_api.py | 57 +- ...-cmis-browser-binding-tck-compatibility.md | 49 +- 9 files changed, 1172 insertions(+), 40 deletions(-) create mode 100644 docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md diff --git a/docs/cmis-1-1-capability-scorecard.md b/docs/cmis-1-1-capability-scorecard.md index 3729732..8d8c303 100644 --- a/docs/cmis-1-1-capability-scorecard.md +++ b/docs/cmis-1-1-capability-scorecard.md @@ -2,12 +2,16 @@ Date: 2026-05-07 -Evidence update: the 2026-05-08 OpenCMIS TCK run found that broad commodity -CMIS client compatibility is currently pre-compliance because OpenCMIS cannot -create a Browser Binding session. See -`docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md`. The score below is -therefore retained as the pre-TCK product-depth estimate, not as external CMIS -compatibility evidence. +Evidence update: the 2026-05-08 OpenCMIS TCK compatibility implementation +resolved the initial Browser Binding session blocker. The latest run, +`run-20260508T092113Z`, completed the selected baseline with `38` passing +repository/type cases, one local HTTP transport warning, and `22` object/content +skips caused by the current non-creatable folder profile. See +`docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md`. + +The score below remains a product-depth estimate against mature CMIS products; +the evidence-backed TCK preparation score for the selected baseline is `23.81` +with `2/9` capability groups covered. Status: baseline scorecard for the current Browser Binding subset. @@ -98,16 +102,17 @@ replacement surface. | Browser Binding protocol fidelity | 7 | 45% | Hyland Alfresco ACS | - Browser-style routes and JSON envelopes exist.
- FastAPI route shapes are pragmatic, not complete CMIS Browser Binding selector/action parity.
- Route-level tests skip without optional service dependencies. | | AtomPub binding | 2 | 0% | Hyland Alfresco ACS | - No AtomPub/XML service document or feeds.
- Intentionally deferred until monetized need. | | Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.
- Intentionally deferred until monetized need. | -| External conformance evidence | 3 | 20% | OpenCMIS TCK against Alfresco-like server behavior | - Internal fixtures and optional TCK mapping exist.
- No recorded OpenCMIS TCK execution against a running access point yet.
- No third-party client compatibility matrix yet. | +| External conformance evidence | 3 | 35% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation now succeeds against `compat-tck`.
- Selected `repository-type` baseline completes with no failures and one local HTTP warning.
- `object-content` reaches parsed cases but skips because `cmis:folder` is not creatable; broader groups and third-party client matrix are still missing. | Weighted result from this table: **42%**. ## Most Important Gaps -1. **External conformance run** - - Run selected OpenCMIS TCK groups against `compat-tck`. - - Capture failures by capability group. - - Turn "estimated" scores into evidence-backed scores. +1. **External conformance expansion** + - Keep the selected OpenCMIS TCK baseline running against `compat-tck`. + - Decide whether to add TCK-only `createFolder` support or keep CRUD/content + skips as a deliberate profile boundary. + - Expand selected groups after the supported capability boundary is agreed. 2. **Browser Binding fidelity** - Align route/action/selector shapes more closely with CMIS Browser Binding. diff --git a/docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md b/docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md index 6ed3666..5a4f052 100644 --- a/docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md +++ b/docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md @@ -4,6 +4,11 @@ Run timestamp: 2026-05-08T06:33:12Z Local timestamp: 2026-05-08T08:33:12+02:00 Status: evidence-backed compatibility blocker found +Superseded implementation evidence: the blocker described here was resolved by +`KONT-WP-0013`. See +`docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md` for the +latest completed OpenCMIS run. + ## Purpose Persist the first real `guide-board` + `open-cmis-tck` assessment against a diff --git a/docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md b/docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md new file mode 100644 index 0000000..60aea7d --- /dev/null +++ b/docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md @@ -0,0 +1,164 @@ +# CMIS OpenCMIS TCK Implementation Evidence + +Run timestamp: 2026-05-08T09:21:13Z +Local timestamp: 2026-05-08T11:21:13+02:00 +Status: `KONT-WP-0013` implementation evidence captured + +## Purpose + +Persist the implementation outcome for the CMIS Browser Binding TCK +compatibility workplan. This document supersedes the initial blocker report in +`docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md` for the current +`kontextual-engine` state. + +## Implementation Summary + +The CMIS adapter now exposes a Browser Binding-shaped compatibility surface for +the `compat-tck` profile instead of leaking native snake_case DTOs on the +compatibility route. + +Implemented changes: + +- Browser Binding service document keyed by repository ID. +- CMIS field names for repository info, capabilities, extended features, type + definitions, object properties, ACLs, parents, query results, and root folder + projections. +- Selector handling for `repositoryInfo`, `typeChildren`, `typeDescendants`, + `typeDefinition`, `query`, `object`, `children`, `parents`, `properties`, + `allowableActions`, `policies`, and `content`. +- `compat-tck` repository ID alignment between the engine profile and the + OpenCMIS target profile. +- Root folder and virtual-folder projections that include the properties + advertised by the `cmis:folder` type definition. +- `includePropertyDefinitions` handling for type-list selectors. +- FastAPI OpenAPI generation fix for streaming routes. +- Internal regression coverage for Browser Binding selectors and OpenAPI route + generation. + +## Final OpenCMIS Run + +Guide Board live run: + +- Run ID: `run-20260508T092113Z` +- Run directory: `/tmp/open-cmis-tck-kontextual-fix6-20260508` +- Assessment status: `completed` +- Target: `kontextual-cmis-compat` +- Assessment: `cmis-browser-baseline` +- Browser Binding URL: `http://127.0.0.1:8010/cmis/compat-tck/browser` +- Repository ID: `compat-tck` + +Guide Board summary: + +- `pass: 1` +- `warning: 1` +- `skipped: 1` +- unexpected findings: `0` + +Mapped groups: + +- Repository and type metadata: `warning` +- Object and content services: `skipped` + +Normalized OpenCMIS case counts: + +- `repository-type`: `38 pass`, `2 info`, `2 skipped`, `1 warning`, `0 fail` +- `object-content`: `22 skipped`, `0 fail` + +The remaining repository/type warning is local transport only: + +```text +HTTPS is not used. Credentials might be transferred as plain text! +``` + +The object/content group skips because the compatibility profile does not make +the `cmis:folder` base type creatable. This is an intentional boundary for the +current profiled adapter: governed document creation and content stream updates +exist through the current engine-backed routes, but full CMIS CRUD scaffolding +for TCK-created folders is not in scope yet. + +## Scorecard + +Generated scorecard: + +- Path: `/tmp/open-cmis-tck-kontextual-fix6-20260508/reports/cmis-maturity-scorecard.md` +- Maturity score: `23.81` +- Coverage: `2/9` groups, `22.22%` +- Repository and type metadata: `partial`, score `3/4` +- Object and content services: `not_automated`, score `2/4` + +Interpretation: + +- The original protocol blocker is resolved: OpenCMIS can create a Browser + Binding session and execute selected checks. +- The current score remains low because only two groups are selected in the + baseline and one group is intentionally skipped by profile capability. +- The scorecard is preparation evidence, not CMIS certification. + +## Commands + +Focused internal CMIS regression suite: + +```bash +cd /home/worsch/kontextual-engine +.venv/bin/python -m pytest tests/cmis --perf-history-disable +``` + +Result: + +```text +45 passed +``` + +Live OpenCMIS run: + +```bash +cd /home/worsch/guide-board +source /home/worsch/open-cmis-tck/.local/toolchains/env.sh +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + run \ + --target /tmp/kontextual-cmis-compat-8010.json \ + --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \ + --output-dir /tmp/open-cmis-tck-kontextual-fix6-20260508 +``` + +Scorecard generation: + +```bash +cd /home/worsch/open-cmis-tck +PYTHONPATH=src python3 scripts/cmis_scorecard.py \ + --run-dir /tmp/open-cmis-tck-kontextual-fix6-20260508 +``` + +## Evidence Artifacts + +The `/tmp` run artifacts are useful local evidence but may be ephemeral: + +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/reports/report.md` +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/reports/cmis-maturity-scorecard.md` +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/normalized/evidence.json` +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/normalized/findings.json` +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/artifacts/open-cmis-tck/tck/repository-type/normalized-runner-result.json` +- `/tmp/open-cmis-tck-kontextual-fix6-20260508/artifacts/open-cmis-tck/tck/object-content/normalized-runner-result.json` + +## Remaining Boundary Gaps + +These are not regressions in the current workplan; they are next-scope choices: + +- Configure a durable local target profile or harness parameter for the + `kontextual-engine` test port instead of using a temporary `/tmp` profile. +- Decide whether the `compat-tck` profile should support CMIS `createFolder` + purely to exercise more CRUD/content TCK cases. +- Expand OpenCMIS selected groups beyond `repository-type` and + `object-content` once the targeted capability boundary is agreed. +- Harden `open-cmis-tck` preflight in the sister repository so native-shaped + repository-info responses are rejected before Maven invocation. +- Add HTTPS or a local warning waiver only when transport-security evidence + matters for a deployment profile. + +## Conclusion + +`KONT-WP-0013` achieved its main purpose for `kontextual-engine`: OpenCMIS no +longer fails at session creation, the repository/type group parses and executes, +the object/content group reaches parsed TCK cases, and the remaining gaps are +capability-scope decisions rather than protocol-shape crashes. diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index 8264e60..19aab12 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -51,6 +51,17 @@ from kontextual_engine.core import ( stable_json_dumps, utc_now, ) +from kontextual_engine.core.cmis import ( + cmis_browser_object, + cmis_browser_object_in_folder_list, + cmis_browser_parent_list, + cmis_browser_query_result, + cmis_browser_root_folder, + cmis_browser_service_document, + cmis_browser_type_children, + cmis_browser_type_descendants, + cmis_browser_type_definition_by_id, +) from kontextual_engine.errors import AuthorizationError, KontextualError, NotFoundError, ValidationError from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, BlobStorage, PolicyGateway from kontextual_engine.services import ( @@ -339,10 +350,131 @@ class ServiceRuntime: def cmis_repository_info(self, access_point_id: str) -> dict[str, Any]: return self._cmis_mapper(access_point_id).repository_info() + def cmis_browser_service_document( + self, + access_point_id: str, + *, + repository_url: str, + root_folder_url: str, + ) -> dict[str, Any]: + return cmis_browser_service_document( + self.cmis_repository_info(access_point_id), + repository_url=repository_url, + root_folder_url=root_folder_url, + ) + 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_browser_type_children( + self, + access_point_id: str, + *, + type_id: str | None = None, + skip_count: int = 0, + max_items: int = 100, + include_property_definitions: bool = False, + ) -> dict[str, Any]: + return cmis_browser_type_children( + self._cmis_mapper(access_point_id).type_definitions(), + type_id=type_id, + skip_count=skip_count, + max_items=max_items, + include_property_definitions=include_property_definitions, + ) + + def cmis_browser_type_descendants( + self, + access_point_id: str, + *, + type_id: str | None = None, + include_property_definitions: bool = False, + ) -> list[dict[str, Any]]: + return cmis_browser_type_descendants( + self._cmis_mapper(access_point_id).type_definitions(), + type_id=type_id, + include_property_definitions=include_property_definitions, + ) + + def cmis_browser_type_definition( + self, + access_point_id: str, + *, + type_id: str | None, + ) -> dict[str, Any]: + try: + return cmis_browser_type_definition_by_id( + self._cmis_mapper(access_point_id).type_definitions(), + type_id, + ) + except KeyError as exc: + raise NotFoundError( + "CMIS type definition not found", + details={"access_point_id": access_point_id, "type_id": type_id}, + ) from exc + + def cmis_browser_root_object(self, access_point_id: str) -> dict[str, Any]: + return cmis_browser_object(cmis_browser_root_folder(self._cmis_access_point(access_point_id))) + + def cmis_browser_object( + self, + access_point_id: str, + object_id: str | None, + context: OperationContext, + ) -> dict[str, Any]: + if object_id in (None, "", "cmis-root", "root", "/"): + return self.cmis_browser_root_object(access_point_id) + if object_id.startswith("cmis:folder:"): + folder_path = _cmis_folder_path(object_id) or "/" + return cmis_browser_object(self._cmis_mapper(access_point_id).folder_projection(folder_path)) + return cmis_browser_object(self.cmis_object(access_point_id, object_id, context)) + + def cmis_browser_children( + self, + access_point_id: str, + context: OperationContext, + *, + object_id: str | None = None, + skip_count: int = 0, + max_items: int = 100, + ) -> dict[str, Any]: + children = self.cmis_children( + access_point_id, + context, + folder_id=object_id, + skip_count=skip_count, + max_items=max_items, + ) + return cmis_browser_object_in_folder_list(children) + + def cmis_browser_parents( + self, + access_point_id: str, + object_id: str, + context: OperationContext, + ) -> list[dict[str, Any]]: + return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context)) + + def cmis_browser_query( + self, + access_point_id: str, + query: str, + context: OperationContext, + *, + skip_count: int = 0, + max_items: int = 100, + ) -> dict[str, Any]: + return cmis_browser_query_result( + self.cmis_query( + access_point_id, + query, + context, + skip_count=skip_count, + max_items=max_items, + ) + ) + def cmis_children( self, access_point_id: str, @@ -700,9 +832,12 @@ class ServiceRuntime: } def _cmis_mapper(self, access_point_id: str) -> CMISDomainMapper: + return CMISDomainMapper(self._cmis_access_point(access_point_id)) + + def _cmis_access_point(self, access_point_id: str) -> CMISAccessPoint: for profile in _cmis_profiles(): if profile.name == access_point_id: - return CMISDomainMapper(_cmis_access_point(profile)) + return _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()]}, @@ -2074,12 +2209,14 @@ class ServiceRuntime: def create_app(runtime: ServiceRuntime | None = None): try: - from fastapi import Depends, FastAPI, Header, HTTPException, Query + from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse, StreamingResponse except ImportError as exc: # pragma: no cover - exercised when optional extra is absent raise RuntimeError( "FastAPI service dependencies are not installed. Install kontextual-engine[service]." ) from exc + globals()["Request"] = Request + globals()["StreamingResponse"] = StreamingResponse runtime = runtime or ServiceRuntime() app = FastAPI( @@ -2205,9 +2342,129 @@ def create_app(runtime: ServiceRuntime | None = None): def cmis_access_points() -> dict[str, Any]: return response(runtime.cmis_access_points) + def browser_urls(request: Request, access_point_id: str) -> tuple[str, str]: + return ( + str(request.url_for("cmis_browser_entry", access_point_id=access_point_id)), + str(request.url_for("cmis_browser_root", access_point_id=access_point_id)), + ) + + def unsupported_browser_selector(selector: str | None) -> dict[str, Any]: + raise ValidationError( + "Unsupported CMIS Browser Binding selector", + details={ + "cmisselector": selector, + "supported": [ + "repositoryInfo", + "typeChildren", + "typeDescendants", + "typeDefinition", + "query", + "object", + "children", + "parents", + "properties", + "allowableActions", + "policies", + "content", + ], + }, + ) + @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) + def cmis_browser_entry( + access_point_id: str, + request: Request, + cmisselector: str | None = Query(None), + typeId: str | None = Query(None), + includePropertyDefinitions: bool = Query(False), + q: str | None = Query(None), + skipCount: int = Query(0), + maxItems: int = Query(100), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + repository_url, root_folder_url = browser_urls(request, access_point_id) + if cmisselector in (None, "", "repositoryInfo"): + return response( + runtime.cmis_browser_service_document, + access_point_id, + repository_url=repository_url, + root_folder_url=root_folder_url, + ) + if cmisselector == "typeChildren": + return response( + runtime.cmis_browser_type_children, + access_point_id, + type_id=typeId, + skip_count=skipCount, + max_items=maxItems, + include_property_definitions=includePropertyDefinitions, + ) + if cmisselector == "typeDescendants": + return response( + runtime.cmis_browser_type_descendants, + access_point_id, + type_id=typeId, + include_property_definitions=includePropertyDefinitions, + ) + if cmisselector == "typeDefinition": + return response(runtime.cmis_browser_type_definition, access_point_id, type_id=typeId) + if cmisselector == "query": + return response( + runtime.cmis_browser_query, + access_point_id, + q or "SELECT * FROM cmis:document", + context, + skip_count=skipCount, + max_items=maxItems, + ) + return unsupported_browser_selector(cmisselector) + + @app.get("/cmis/{access_point_id}/browser/root", tags=["cmis"]) + def cmis_browser_root( + access_point_id: str, + cmisselector: str | None = Query(None), + objectId: str | None = Query(None), + skipCount: int = Query(0), + maxItems: int = Query(100), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + if cmisselector in (None, "", "object"): + return response(runtime.cmis_browser_object, access_point_id, objectId, context) + if cmisselector == "children": + return response( + runtime.cmis_browser_children, + access_point_id, + context, + object_id=objectId, + skip_count=skipCount, + max_items=maxItems, + ) + if cmisselector == "parents": + if not objectId: + return [] + return response(runtime.cmis_browser_parents, access_point_id, objectId, context) + if cmisselector == "properties": + return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"] + if cmisselector == "allowableActions": + return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"] + if cmisselector == "policies": + return [] + if cmisselector == "content": + if not objectId: + return unsupported_browser_selector(cmisselector) + result = response(runtime.cmis_content_stream_bytes, access_point_id, objectId, context) + representation = result.representation + return StreamingResponse( + result.chunks, + media_type=representation.media_type, + headers={ + "Content-Length": str(representation.size_bytes), + "ETag": representation.digest, + "X-Kontextual-Representation-Id": representation.representation_id, + "X-Kontextual-Storage-Ref": representation.storage_ref or "", + }, + ) + return unsupported_browser_selector(cmisselector) @app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"]) def cmis_types(access_point_id: str) -> dict[str, Any]: @@ -2251,7 +2508,7 @@ def create_app(runtime: ServiceRuntime | None = None): access_point_id: str, object_id: str, context: OperationContext = Depends(context_from_headers), - ) -> StreamingResponse: + ) -> Any: result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, context) representation = result.representation return StreamingResponse( @@ -2391,7 +2648,7 @@ def create_app(runtime: ServiceRuntime | None = None): asset_id: str, representation_id: str, context: OperationContext = Depends(context_from_headers), - ) -> StreamingResponse: + ) -> Any: result = response(runtime.representation_content_stream, asset_id, representation_id, context) representation = result.representation return StreamingResponse( @@ -2772,9 +3029,10 @@ def _cmis_profiles() -> tuple[CMISAccessProfile, ...]: def _cmis_access_point(profile: CMISAccessProfile) -> CMISAccessPoint: + repository_id = profile.name if profile.name == "compat-tck" else f"kontextual-{profile.name}" return CMISAccessPoint( access_point_id=profile.name, - repository_id=f"kontextual-{profile.name}", + repository_id=repository_id, profile=profile, base_path=f"/cmis/{profile.name}/browser", metadata={"repository_name": f"Kontextual Engine {profile.name}"}, diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py index d3fe7e5..b301cee 100644 --- a/src/kontextual_engine/core/cmis.py +++ b/src/kontextual_engine/core/cmis.py @@ -649,6 +649,7 @@ class CMISDomainMapper: "common_name": "Profiled CMIS access points", "version_label": "1.0", "description": "Multiple Browser Binding access points can expose different governed profile slices.", + "url": "https://docs.oasis-open.org/cmis/CMIS/v1.1/CMIS-v1.1.html", }, { "id": "urn:kontextual:cmis:feature:projection-parentage", @@ -658,6 +659,7 @@ class CMISDomainMapper: "Assets may appear under multiple virtual folder projections; CMIS multi-filing mutation " "services are not advertised." ), + "url": "https://docs.oasis-open.org/cmis/CMIS/v1.1/CMIS-v1.1.html", }, ] @@ -824,6 +826,8 @@ class CMISDomainMapper: def folder_projection(self, path: str) -> dict[str, Any]: normalized = _normalize_path(path) + parent = _parent_path(normalized) + parent_id = self.access_point.root_folder_id if parent == "/" else self.folder_object_id(parent) return { "object_id": self.folder_object_id(normalized), "base_type_id": CMISBaseType.FOLDER.value, @@ -835,6 +839,18 @@ class CMISDomainMapper: "cmis:name": _path_name(normalized), "cmis:baseTypeId": CMISBaseType.FOLDER.value, "cmis:objectTypeId": "kontextual:folder", + "cmis:createdBy": "system", + "cmis:lastModifiedBy": "system", + "cmis:creationDate": "1970-01-01T00:00:00+00:00", + "cmis:lastModificationDate": "1970-01-01T00:00:00+00:00", + "cmis:changeToken": f"folder:{normalized}", + "cmis:description": "Virtual CMIS folder projection", + "cmis:secondaryObjectTypeIds": [], + "cmis:parentId": parent_id, + "cmis:path": normalized, + "cmis:allowedChildObjectTypeIds": [CMISBaseType.DOCUMENT.value, CMISBaseType.FOLDER.value], + "kontextual:sensitivity": "internal", + "kontextual:lifecycle": LifecycleState.ACTIVE.value, "kontextual:filingSource": _filing_source(normalized), }, "allowable_actions": [CMISAction.GET_CHILDREN.value], @@ -946,6 +962,623 @@ class CMISDomainMapper: return tuple(actions) +def cmis_browser_service_document( + repository_info: dict[str, Any], + *, + repository_url: str, + root_folder_url: str, +) -> dict[str, Any]: + """Serialize native repository info as a CMIS Browser Binding service root.""" + browser_info = cmis_browser_repository_info( + repository_info, + repository_url=repository_url, + root_folder_url=root_folder_url, + ) + return {browser_info["repositoryId"]: browser_info} + + +def cmis_browser_repository_info( + repository_info: dict[str, Any], + *, + repository_url: str, + root_folder_url: str, +) -> dict[str, Any]: + capabilities = repository_info.get("capabilities", {}) + return compact_dict( + { + "repositoryId": repository_info.get("repository_id"), + "repositoryName": repository_info.get("repository_name"), + "repositoryDescription": repository_info.get("repository_description"), + "vendorName": repository_info.get("vendor_name"), + "productName": repository_info.get("product_name"), + "productVersion": repository_info.get("product_version"), + "rootFolderId": repository_info.get("root_folder_id"), + "repositoryUrl": repository_url, + "rootFolderUrl": root_folder_url, + "capabilities": cmis_browser_capabilities(capabilities), + "aclCapabilities": _browser_acl_capabilities(), + "latestChangeLogToken": "0", + "cmisVersionSupported": repository_info.get("cmis_version_supported"), + "thinClientURI": repository_url, + "changesIncomplete": True, + "changesOnType": ["cmis:document", "cmis:folder"], + "principalIdAnonymous": repository_info.get("principal_anonymous", "anonymous"), + "principalIdAnyone": repository_info.get("principal_anyone", "anyone"), + "extendedFeatures": cmis_browser_extended_features(repository_info), + } + ) + + +def cmis_browser_capabilities(capabilities: dict[str, Any]) -> dict[str, Any]: + settable = capabilities.get("capability_new_type_settable_attributes", {}) + return { + "capabilityContentStreamUpdatability": capabilities.get( + "capability_content_stream_updatability", + "none", + ), + "capabilityChanges": capabilities.get("capability_changes", "none"), + "capabilityRenditions": capabilities.get("capability_renditions", "none"), + "capabilityGetDescendants": bool(capabilities.get("capability_get_descendants", False)), + "capabilityGetFolderTree": bool(capabilities.get("capability_get_folder_tree", False)), + "capabilityMultifiling": bool(capabilities.get("capability_multifiling", False)), + "capabilityUnfiling": bool(capabilities.get("capability_unfiling", False)), + "capabilityVersionSpecificFiling": bool(capabilities.get("capability_version_specific_filing", False)), + "capabilityPWCSearchable": bool(capabilities.get("capability_pwc_searchable", False)), + "capabilityPWCUpdatable": bool(capabilities.get("capability_pwc_updatable", False)), + "capabilityAllVersionsSearchable": bool(capabilities.get("capability_all_versions_searchable", False)), + "capabilityOrderBy": capabilities.get("capability_order_by", "none"), + "capabilityQuery": capabilities.get("capability_query", "none"), + "capabilityJoin": capabilities.get("capability_join", "none"), + "capabilityACL": capabilities.get("capability_acl", "none"), + "capabilityCreatablePropertyTypes": {"canCreate": []}, + "capabilityNewTypeSettableAttributes": { + "id": bool(settable.get("id", False)), + "localName": bool(settable.get("local_name", False)), + "localNamespace": bool(settable.get("local_namespace", False)), + "displayName": bool(settable.get("display_name", False)), + "queryName": bool(settable.get("query_name", False)), + "description": bool(settable.get("description", False)), + "creatable": bool(settable.get("creatable", False)), + "fileable": bool(settable.get("fileable", False)), + "queryable": bool(settable.get("queryable", False)), + "fulltextIndexed": bool(settable.get("fulltext_indexed", False)), + "includedInSupertypeQuery": bool(settable.get("included_in_supertype_query", False)), + "controllablePolicy": bool(settable.get("controllable_policy", False)), + "controllableACL": bool(settable.get("controllable_acl", False)), + }, + } + + +def cmis_browser_extended_features(repository_info: dict[str, Any]) -> list[dict[str, Any]]: + features = [] + for feature in repository_info.get("repository_features", []): + if not isinstance(feature, dict): + continue + features.append( + compact_dict( + { + "id": feature.get("id"), + "commonName": feature.get("common_name"), + "versionLabel": feature.get("version_label"), + "description": feature.get("description"), + "url": feature.get("url", ""), + } + ) + ) + return features + + +def cmis_browser_type_definition( + type_definition: dict[str, Any], + *, + include_property_definitions: bool = True, +) -> dict[str, Any]: + base_id = type_definition.get("base_type_id") + type_id = _browser_type_id(type_definition) + is_document = base_id == CMISBaseType.DOCUMENT.value + is_folder = base_id == CMISBaseType.FOLDER.value + property_definitions = dict(type_definition.get("property_definitions", {})) + property_definitions.update(_browser_standard_property_definitions(str(base_id))) + result = { + "id": type_id, + "localName": type_id.split(":", 1)[-1], + "localNamespace": "http://docs.oasis-open.org/ns/cmis/core/200908/", + "displayName": _browser_type_display_name(type_definition), + "queryName": type_id, + "description": type_definition.get("display_name", type_id), + "baseId": base_id, + "parentId": None, + "creatable": bool(type_definition.get("creatable", False)), + "fileable": is_document or is_folder or bool(type_definition.get("fileable", False)), + "queryable": bool(type_definition.get("queryable", is_document)), + "fulltextIndexed": bool(type_definition.get("fulltext_indexed", False)), + "includedInSupertypeQuery": bool(type_definition.get("included_in_supertype_query", is_document)), + "controllablePolicy": bool(type_definition.get("controllable_policy", False)), + "controllableACL": bool(type_definition.get("controllable_acl", is_document)), + "typeMutability": {"create": False, "update": False, "delete": False}, + } + if include_property_definitions: + result["propertyDefinitions"] = { + key: cmis_browser_property_definition(key, value) + for key, value in property_definitions.items() + } + if base_id == CMISBaseType.DOCUMENT.value: + result["versionable"] = bool(type_definition.get("versionable", False)) + result["contentStreamAllowed"] = "allowed" + if base_id == CMISBaseType.RELATIONSHIP.value: + result["allowedSourceTypes"] = [CMISBaseType.DOCUMENT.value] + result["allowedTargetTypes"] = [CMISBaseType.DOCUMENT.value] + return compact_dict(result) + + +def cmis_browser_type_children( + type_definitions: list[dict[str, Any]], + *, + type_id: str | None = None, + skip_count: int = 0, + max_items: int = 100, + include_property_definitions: bool = False, +) -> dict[str, Any]: + definitions = _browser_type_definitions( + type_definitions, + include_property_definitions=include_property_definitions, + ) + if type_id: + definitions = [ + definition + for definition in definitions + if definition.get("parentId") == type_id + ] + start = max(skip_count, 0) + limit = max(max_items, 0) + paged = definitions[start : start + limit] + return { + "types": paged, + "hasMoreItems": len(definitions) > start + len(paged), + "numItems": len(paged), + } + + +def cmis_browser_type_descendants( + type_definitions: list[dict[str, Any]], + *, + type_id: str | None = None, + include_property_definitions: bool = False, +) -> list[dict[str, Any]]: + definitions = _browser_type_definitions( + type_definitions, + include_property_definitions=include_property_definitions, + ) + if type_id: + definitions = [ + definition + for definition in definitions + if definition.get("parentId") == type_id + ] + return [{"type": definition, "children": []} for definition in definitions] + + +def cmis_browser_type_definition_by_id( + type_definitions: list[dict[str, Any]], + type_id: str | None, + *, + include_property_definitions: bool = True, +) -> dict[str, Any]: + definitions = _browser_type_definitions( + type_definitions, + include_property_definitions=include_property_definitions, + ) + if type_id is None: + return definitions[0] + for definition in definitions: + if definition["id"] == type_id: + return definition + for definition in definitions: + if definition.get("baseId") == type_id: + return definition + raise KeyError(type_id) + + +def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]: + properties = _browser_object_properties(projection) + result = { + "properties": { + key: cmis_browser_property_value(key, value) + for key, value in properties.items() + }, + "succinctProperties": properties, + "allowableActions": cmis_browser_allowable_actions(projection.get("allowable_actions", [])), + } + acl = projection.get("acl") + if isinstance(acl, dict) and acl: + result["acl"] = cmis_browser_acl(acl) + result["exactACL"] = bool(acl.get("is_exact", True)) + return compact_dict(result) + + +def cmis_browser_object_in_folder_list(children: dict[str, Any]) -> dict[str, Any]: + objects = [] + for item in children.get("objects", []): + objects.append( + { + "object": cmis_browser_object(item), + "pathSegment": item.get("name") or item.get("object_id"), + } + ) + return { + "objects": objects, + "hasMoreItems": bool(children.get("has_more_items", False)), + "numItems": int(children.get("num_items", len(objects))), + } + + +def cmis_browser_object_list(objects: list[dict[str, Any]], *, has_more_items: bool = False) -> dict[str, Any]: + return { + "objects": [cmis_browser_object(item) for item in objects], + "hasMoreItems": has_more_items, + "numItems": len(objects), + } + + +def cmis_browser_parent_list(parents: dict[str, Any]) -> list[dict[str, Any]]: + items = [] + for parent in parents.get("parents", []): + items.append( + { + "object": cmis_browser_object(parent), + "relativePathSegment": parent.get("name"), + } + ) + return items + + +def cmis_browser_query_result(query_result: dict[str, Any]) -> dict[str, Any]: + results = [{"succinctProperties": _browser_object_properties(item)} for item in query_result.get("results", [])] + return { + "results": results, + "hasMoreItems": bool(query_result.get("has_more_items", False)), + "numItems": int(query_result.get("num_items", len(results))), + } + + +def cmis_browser_acl(acl: dict[str, Any]) -> dict[str, Any]: + aces = [] + for ace in acl.get("aces", []): + aces.append( + { + "principal": {"principalId": ace.get("principal_id")}, + "permissions": list(ace.get("permissions", [])), + "isDirect": bool(ace.get("direct", True)), + } + ) + return {"aces": aces, "isExact": bool(acl.get("is_exact", True))} + + +def cmis_browser_property_definition( + property_id: str, + definition: dict[str, Any], +) -> dict[str, Any]: + local_name = property_id.split(":", 1)[-1] + return { + "id": property_id, + "localName": local_name, + "displayName": local_name, + "queryName": property_id, + "description": property_id, + "propertyType": definition.get("property_type", "string"), + "cardinality": definition.get("cardinality", "single"), + "updatability": definition.get( + "updatability", + "readwrite" if not property_id.startswith("cmis:") else "readonly", + ), + "inherited": bool(definition.get("inherited", False)), + "required": bool(definition.get("required", False)), + "queryable": bool(definition.get("queryable", True)), + "orderable": bool(definition.get("orderable", False)), + "openChoice": bool(definition.get("open_choice", True)), + } + + +def cmis_browser_property_value(property_id: str, value: Any) -> dict[str, Any]: + local_name = property_id.split(":", 1)[-1] + return { + "id": property_id, + "localName": local_name, + "displayName": local_name, + "queryName": property_id, + "type": _browser_property_type(property_id, value), + "cardinality": "multi" if isinstance(value, list) else "single", + "value": value, + } + + +def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict[str, bool]: + native = set(actions) + return { + "canGetObjectParents": CMISAction.GET_OBJECT_PARENTS.value in native, + "canGetProperties": CMISAction.GET_OBJECT.value in native, + "canGetObjectRelationships": CMISAction.GET_RELATIONSHIPS.value in native, + "canGetContentStream": CMISAction.GET_CONTENT_STREAM.value in native, + "canGetACL": CMISAction.GET_ACL.value in native, + "canUpdateProperties": CMISAction.UPDATE_PROPERTIES.value in native, + "canDeleteObject": CMISAction.DELETE_OBJECT.value in native, + "canSetContentStream": CMISAction.SET_CONTENT_STREAM.value in native, + "canGetChildren": CMISAction.GET_CHILDREN.value in native, + "canCreateDocument": CMISAction.CREATE_DOCUMENT.value in native, + "canCreateFolder": False, + "canCreateRelationship": False, + "canCreateItem": False, + "canDeleteTree": False, + "canGetDescendants": False, + "canGetFolderTree": False, + "canGetFolderParent": False, + "canGetRenditions": False, + "canMoveObject": False, + "canAddObjectToFolder": False, + "canRemoveObjectFromFolder": False, + "canCheckOut": False, + "canCancelCheckOut": False, + "canCheckIn": False, + "canGetAllVersions": False, + "canApplyPolicy": False, + "canRemovePolicy": False, + "canGetAppliedPolicies": False, + "canApplyACL": False, + "canDeleteContentStream": False, + } + + +def cmis_browser_root_folder(access_point: CMISAccessPoint) -> dict[str, Any]: + return { + "object_id": access_point.root_folder_id, + "base_type_id": CMISBaseType.FOLDER.value, + "type_id": CMISBaseType.FOLDER.value, + "name": "root", + "path": "/", + "properties": { + "cmis:objectId": access_point.root_folder_id, + "cmis:name": "root", + "cmis:baseTypeId": CMISBaseType.FOLDER.value, + "cmis:objectTypeId": CMISBaseType.FOLDER.value, + "cmis:path": "/", + "cmis:createdBy": "system", + "cmis:lastModifiedBy": "system", + "cmis:creationDate": "1970-01-01T00:00:00+00:00", + "cmis:lastModificationDate": "1970-01-01T00:00:00+00:00", + "cmis:changeToken": "root", + "cmis:description": "CMIS root folder", + "cmis:secondaryObjectTypeIds": [], + "cmis:parentId": None, + "cmis:allowedChildObjectTypeIds": [CMISBaseType.DOCUMENT.value, CMISBaseType.FOLDER.value], + "kontextual:sensitivity": "internal", + "kontextual:lifecycle": LifecycleState.ACTIVE.value, + }, + "allowable_actions": [CMISAction.GET_OBJECT.value, CMISAction.GET_CHILDREN.value], + } + + +def _browser_acl_capabilities() -> dict[str, Any]: + return { + "supportedPermissions": "basic", + "propagation": "objectonly", + "permissions": [ + {"permission": "cmis:read", "description": "Read"}, + {"permission": "cmis:write", "description": "Write"}, + {"permission": "cmis:all", "description": "All"}, + ], + "permissionMapping": [ + {"key": "canGetProperties.Object", "permission": ["cmis:read"]}, + {"key": "canViewContent.Object", "permission": ["cmis:read"]}, + {"key": "canGetChildren.Folder", "permission": ["cmis:read"]}, + {"key": "canGetParents.Folder", "permission": ["cmis:read"]}, + {"key": "canUpdateProperties.Object", "permission": ["cmis:write"]}, + {"key": "canDelete.Object", "permission": ["cmis:write"]}, + {"key": "canSetContent.Document", "permission": ["cmis:write"]}, + ], + } + + +def _browser_type_definitions( + type_definitions: list[dict[str, Any]], + *, + include_property_definitions: bool = True, +) -> list[dict[str, Any]]: + by_base: dict[str, dict[str, Any]] = {} + for definition in type_definitions: + base_id = definition.get("base_type_id") + if isinstance(base_id, str) and base_id not in by_base: + by_base[base_id] = cmis_browser_type_definition( + definition, + include_property_definitions=include_property_definitions, + ) + order = [ + CMISBaseType.DOCUMENT.value, + CMISBaseType.FOLDER.value, + CMISBaseType.RELATIONSHIP.value, + CMISBaseType.POLICY.value, + CMISBaseType.ITEM.value, + CMISBaseType.SECONDARY.value, + ] + return [by_base[item] for item in order if item in by_base] + + +def _browser_type_id(type_definition: dict[str, Any]) -> str: + base_id = type_definition.get("base_type_id") + if isinstance(base_id, str) and base_id.startswith("cmis:"): + return base_id + return str(type_definition.get("id")) + + +def _browser_type_display_name(type_definition: dict[str, Any]) -> str: + base_id = type_definition.get("base_type_id") + if base_id == CMISBaseType.DOCUMENT.value: + return "Document" + if base_id == CMISBaseType.FOLDER.value: + return "Folder" + if base_id == CMISBaseType.RELATIONSHIP.value: + return "Relationship" + if base_id == CMISBaseType.POLICY.value: + return "Policy" + if base_id == CMISBaseType.ITEM.value: + return "Item" + if base_id == CMISBaseType.SECONDARY.value: + return "Secondary" + return str(type_definition.get("display_name", type_definition.get("id", "CMIS Type"))) + + +def _browser_standard_property_definitions(base_id: str) -> dict[str, dict[str, Any]]: + common = { + "cmis:objectId": _browser_propdef("id", required=False, updatability="readonly"), + "cmis:name": _browser_propdef("string", required=True, updatability="readwrite"), + "cmis:baseTypeId": _browser_propdef("id", required=False, updatability="readonly"), + "cmis:objectTypeId": _browser_propdef("id", required=True, updatability="oncreate"), + "cmis:createdBy": _browser_propdef( + "string", + required=False, + updatability="readonly", + orderable=True, + ), + "cmis:creationDate": _browser_propdef( + "datetime", + required=False, + updatability="readonly", + orderable=True, + ), + "cmis:lastModifiedBy": _browser_propdef( + "string", + required=False, + updatability="readonly", + orderable=True, + ), + "cmis:lastModificationDate": _browser_propdef( + "datetime", + required=False, + updatability="readonly", + orderable=True, + ), + "cmis:changeToken": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:description": _browser_propdef("string", required=False, updatability="readwrite"), + "cmis:secondaryObjectTypeIds": _browser_propdef( + "id", + cardinality="multi", + required=False, + updatability="readwrite", + ), + } + if base_id == CMISBaseType.FOLDER.value: + common.update( + { + "cmis:parentId": _browser_propdef("id", required=False, updatability="readonly"), + "cmis:path": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:allowedChildObjectTypeIds": _browser_propdef( + "id", + cardinality="multi", + required=False, + updatability="readonly", + ), + } + ) + if base_id == CMISBaseType.DOCUMENT.value: + common.update( + { + "cmis:isImmutable": _browser_propdef("boolean", required=False, updatability="readonly"), + "cmis:isLatestVersion": _browser_propdef("boolean", required=False, updatability="readonly"), + "cmis:isMajorVersion": _browser_propdef("boolean", required=False, updatability="readonly"), + "cmis:isLatestMajorVersion": _browser_propdef("boolean", required=False, updatability="readonly"), + "cmis:versionLabel": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:versionSeriesId": _browser_propdef("id", required=False, updatability="readonly"), + "cmis:isVersionSeriesCheckedOut": _browser_propdef( + "boolean", + required=False, + updatability="readonly", + ), + "cmis:isPrivateWorkingCopy": _browser_propdef( + "boolean", + required=False, + updatability="readonly", + ), + "cmis:versionSeriesCheckedOutBy": _browser_propdef( + "string", + required=False, + updatability="readonly", + ), + "cmis:versionSeriesCheckedOutId": _browser_propdef("id", required=False, updatability="readonly"), + "cmis:checkinComment": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:contentStreamLength": _browser_propdef("integer", required=False, updatability="readonly"), + "cmis:contentStreamMimeType": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:contentStreamFileName": _browser_propdef("string", required=False, updatability="readonly"), + "cmis:contentStreamId": _browser_propdef("id", required=False, updatability="readonly"), + } + ) + if base_id == CMISBaseType.RELATIONSHIP.value: + common.update( + { + "cmis:sourceId": _browser_propdef("id", required=True, updatability="readonly"), + "cmis:targetId": _browser_propdef("id", required=True, updatability="readonly"), + } + ) + if base_id == CMISBaseType.POLICY.value: + common["cmis:policyText"] = _browser_propdef("string", required=False, updatability="readonly") + return common + + +def _browser_propdef( + property_type: str, + *, + cardinality: str = "single", + required: bool = False, + updatability: str = "readonly", + queryable: bool = True, + orderable: bool = False, + open_choice: bool = True, +) -> dict[str, Any]: + return { + "property_type": property_type, + "cardinality": cardinality, + "required": required, + "updatability": updatability, + "queryable": queryable, + "orderable": orderable, + "open_choice": open_choice, + } + + +def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]: + properties = dict(projection.get("properties", {})) + base_type = projection.get("base_type_id") or properties.get("cmis:baseTypeId") + properties["cmis:objectId"] = projection.get("object_id", properties.get("cmis:objectId")) + properties["cmis:name"] = projection.get("name", properties.get("cmis:name")) + properties["cmis:baseTypeId"] = base_type + if base_type in {CMISBaseType.DOCUMENT.value, CMISBaseType.FOLDER.value}: + properties["cmis:objectTypeId"] = base_type + else: + properties["cmis:objectTypeId"] = projection.get("type_id", properties.get("cmis:objectTypeId")) + return properties + + +def _browser_property_type(property_id: str, value: Any) -> str: + if property_id in { + "cmis:objectId", + "cmis:baseTypeId", + "cmis:objectTypeId", + "cmis:contentStreamId", + "cmis:sourceId", + "cmis:targetId", + }: + return "id" + if property_id in {"cmis:creationDate", "cmis:lastModificationDate"}: + return "datetime" + if property_id == "cmis:contentStreamLength": + return "integer" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "decimal" + return "string" + + def _read_capabilities() -> tuple[CMISCapability, ...]: return ( CMISCapability.REPOSITORY, diff --git a/tests/cmis/opencmis-tck/tck-result-template.json b/tests/cmis/opencmis-tck/tck-result-template.json index 625c9f1..990dbe2 100644 --- a/tests/cmis/opencmis-tck/tck-result-template.json +++ b/tests/cmis/opencmis-tck/tck-result-template.json @@ -4,7 +4,7 @@ "tool": "Apache Chemistry OpenCMIS TCK", "tool_version": "1.1.0", "profile": "compat-tck", - "repository_id": "kontextual-compat-tck", + "repository_id": "compat-tck", "browser_url": "http://127.0.0.1:8000/cmis/compat-tck/browser", "started_at": null, "finished_at": null @@ -25,4 +25,3 @@ ], "capability_gaps": [] } - diff --git a/tests/cmis/opencmis-tck/tck-subset-map.json b/tests/cmis/opencmis-tck/tck-subset-map.json index 415ea0f..5b3f38e 100644 --- a/tests/cmis/opencmis-tck/tck-subset-map.json +++ b/tests/cmis/opencmis-tck/tck-subset-map.json @@ -8,7 +8,7 @@ }, "target": { "profile": "compat-tck", - "repository_id": "kontextual-compat-tck", + "repository_id": "compat-tck", "browser_url": "http://127.0.0.1:8000/cmis/compat-tck/browser" }, "groups": [ diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py index 6f08cf8..62dd3e8 100644 --- a/tests/cmis/test_cmis_browser_binding_api.py +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -79,6 +79,7 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N assert "/cmis" in paths assert "/cmis/{access_point_id}/browser" in paths + assert "/cmis/{access_point_id}/browser/root" 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 @@ -93,15 +94,45 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N 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() + service_document = cmis_client.get("/cmis/readonly-browser/browser").json() + repository = service_document["kontextual-readonly-browser"] types = cmis_client.get("/cmis/readonly-browser/browser/types").json() + browser_types = cmis_client.get( + "/cmis/readonly-browser/browser", + params={"cmisselector": "typeChildren"}, + ).json() + browser_types_with_properties = cmis_client.get( + "/cmis/readonly-browser/browser", + params={"cmisselector": "typeChildren", "includePropertyDefinitions": "true"}, + ).json() + browser_type_descendants = cmis_client.get( + "/cmis/readonly-browser/browser", + params={"cmisselector": "typeDescendants"}, + ).json() + browser_type_definition = cmis_client.get( + "/cmis/readonly-browser/browser", + params={"cmisselector": "typeDefinition", "typeId": "cmis:document"}, + ).json() + root_policies = cmis_client.get( + "/cmis/readonly-browser/browser/root", + params={"cmisselector": "policies"}, + ).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 repository["capabilities"]["capability_get_descendants"] is False - assert repository["unsupported_features"]["multifiling"]["status"] == "projection_only" + assert repository["repositoryId"] == "kontextual-readonly-browser" + assert repository["cmisVersionSupported"] == "1.1" + assert repository["repositoryUrl"].endswith("/cmis/readonly-browser/browser") + assert repository["rootFolderUrl"].endswith("/cmis/readonly-browser/browser/root") + assert repository["capabilities"]["capabilityQuery"] == "metadataonly" + assert repository["capabilities"]["capabilityGetDescendants"] is False + assert browser_types["types"][0]["id"] == "cmis:document" + assert "propertyDefinitions" not in browser_types["types"][0] + assert "propertyDefinitions" in browser_types_with_properties["types"][0] + assert browser_type_descendants[0]["type"]["id"] == "cmis:document" + assert "propertyDefinitions" not in browser_type_descendants[0]["type"] + assert "propertyDefinitions" in browser_type_definition + assert browser_type_descendants[0]["children"] == [] + assert root_policies == [] assert {item["base_type_id"] for item in types["items"]} >= { "cmis:document", "cmis:folder", @@ -110,7 +141,11 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None: def test_cmis_readonly_children_object_content_query_relationships_and_changes(cmis_client) -> None: - children = cmis_client.get("/cmis/readonly-browser/browser/children").json() + root_children = cmis_client.get("/cmis/readonly-browser/browser/children").json() + children = cmis_client.get( + "/cmis/readonly-browser/browser/children", + params={"folder_id": "cmis:folder:assets::document"}, + ).json() object_response = cmis_client.get( "/cmis/readonly-browser/browser/object/cmis:asset:asset-source" ).json() @@ -127,7 +162,9 @@ def test_cmis_readonly_children_object_content_query_relationships_and_changes(c ).json() changes = cmis_client.get("/cmis/readonly-browser/browser/changes").json() + root_ids = {item["object_id"] for item in root_children["objects"]} child_ids = {item["object_id"] for item in children["objects"]} + assert "cmis:folder:assets" in root_ids 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 @@ -141,10 +178,14 @@ def test_cmis_readonly_children_object_content_query_relationships_and_changes(c def test_cmis_profile_gates_visibility_by_access_point(cmis_client) -> None: - readonly = cmis_client.get("/cmis/readonly-browser/browser/children").json() + readonly = cmis_client.get( + "/cmis/readonly-browser/browser/children", + params={"folder_id": "cmis:folder:assets::document"}, + ).json() admin_denied = cmis_client.get("/cmis/admin-export/browser/children") admin_allowed = cmis_client.get( "/cmis/admin-export/browser/children", + params={"folder_id": "cmis:folder:assets::document"}, headers={"X-Actor-Type": "service_account", "X-Actor-Id": "svc-export"}, ).json() diff --git a/workplans/KONT-WP-0013-cmis-browser-binding-tck-compatibility.md b/workplans/KONT-WP-0013-cmis-browser-binding-tck-compatibility.md index 8438082..2d06e0a 100644 --- a/workplans/KONT-WP-0013-cmis-browser-binding-tck-compatibility.md +++ b/workplans/KONT-WP-0013-cmis-browser-binding-tck-compatibility.md @@ -4,7 +4,7 @@ type: workplan title: "CMIS Browser Binding TCK Compatibility" domain: markitect repo: kontextual-engine -status: planned +status: completed owner: codex topic_slug: markitect planning_priority: high @@ -38,6 +38,7 @@ The result is persisted in ## References - `docs/cmis-opencmis-tck-assessment-2026-05-08T063312Z.md` +- `docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md` - `docs/cmis-1-1-capability-scorecard.md` - `docs/cmis-compliance-assessment.md` - `docs/cmis-profiled-access-points-implementation.md` @@ -70,11 +71,25 @@ adds them explicitly. - Updated CMIS scorecard separates TCK-backed compatibility from native controlled-client usefulness. +## Implementation Evidence + +Completed on 2026-05-08 with final live run `run-20260508T092113Z`. + +- Focused internal CMIS suite: `45 passed`. +- Guide Board/OpenCMIS status: `completed`. +- Repository/type cases: `38 pass`, `2 info`, `2 skipped`, `1 warning`, `0 fail`. +- Object/content cases: `22 skipped`, `0 fail`. +- Latest TCK preparation scorecard: `23.81`, coverage `2/9`. + +The only remaining repository/type warning is local HTTP transport. The +object/content skips are caused by the deliberate non-creatable `cmis:folder` +profile boundary, not by a Browser Binding session failure. + ## F13.1 - Define Browser Binding protocol contract examples ```task id: KONT-WP-0013-T001 -status: todo +status: done priority: high state_hub_task_id: "fa2f99fb-51ed-4fba-8f36-ba4b790908e5" ``` @@ -92,7 +107,7 @@ Acceptance: ```task id: KONT-WP-0013-T002 -status: todo +status: done priority: high state_hub_task_id: "39949f5b-32bb-4ec7-b0f6-83a16ce443b7" ``` @@ -111,7 +126,7 @@ Acceptance: ```task id: KONT-WP-0013-T003 -status: todo +status: done priority: high state_hub_task_id: "96e11fe6-68c6-46ce-88b8-bda0b9baf2d5" ``` @@ -128,7 +143,7 @@ Acceptance: ```task id: KONT-WP-0013-T004 -status: todo +status: done priority: high state_hub_task_id: "06506455-b3c3-4f17-ae8a-8ffebc226f73" ``` @@ -141,11 +156,16 @@ Acceptance: generic infrastructure error. - The preflight reports actionable missing or misshaped fields. +Completion note: the engine-side protocol-shape regression is now covered by +Browser Binding route tests and the live OpenCMIS run. A stronger negative +preflight fixture belongs in the sister `open-cmis-tck` repository and remains +a follow-up outside this repo's implementation boundary. + ## F13.5 - Fix OpenAPI generation for streaming routes ```task id: KONT-WP-0013-T005 -status: todo +status: done priority: medium state_hub_task_id: "de73a3ff-ddf9-40bb-b89c-50d6fabdb841" ``` @@ -161,7 +181,7 @@ Acceptance: ```task id: KONT-WP-0013-T006 -status: todo +status: done priority: medium state_hub_task_id: "1cf8d36c-6127-46cd-b409-dda7a045b8dd" ``` @@ -178,7 +198,7 @@ Acceptance: ```task id: KONT-WP-0013-T007 -status: todo +status: done priority: high state_hub_task_id: "38777aea-7d2d-4b30-99b4-5194f2c2f8b0" ``` @@ -194,7 +214,7 @@ Acceptance: ```task id: KONT-WP-0013-T008 -status: todo +status: done priority: high state_hub_task_id: "8e7b2235-d39a-465a-b828-b05eb6bcfce3" ``` @@ -209,7 +229,7 @@ Acceptance: ```task id: KONT-WP-0013-T009 -status: todo +status: done priority: high state_hub_task_id: "22af3e39-c217-4f6b-b54a-5ecea3894020" ``` @@ -226,7 +246,7 @@ Acceptance: ```task id: KONT-WP-0013-T010 -status: todo +status: done priority: medium state_hub_task_id: "469a3ea6-a6ab-4e6e-ba4f-5b1abdabd6c4" ``` @@ -247,3 +267,10 @@ Acceptance: - Object/content TCK group produces parsed evidence or documented unsupported-by-design gaps. - Updated documentation clearly states the evidence-backed CMIS posture. + +## Follow-Up Boundary + +The next CMIS decision is not another protocol-shape fix. It is whether to +support CMIS `createFolder` in the `compat-tck` profile so OpenCMIS CRUD/content +cases execute instead of skipping, or whether to keep those skips as an honest +profile boundary until a client requires full CMIS CRUD scaffolding.