generated from coulomb/repo-seed
More CMIS scoring optimization
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
# CMIS 1.1 Capability Scorecard
|
||||
|
||||
Date: 2026-05-07
|
||||
Date: 2026-05-13
|
||||
|
||||
Evidence update: the 2026-05-08 WP-0014 OpenCMIS pass moved the selected
|
||||
baseline beyond the original folder-creatable skip boundary and through several
|
||||
object/content maturity issues. The latest run, `run-20260508T164334Z`, reports
|
||||
`repository-type` as warning-only and reduces `object-content` to specific
|
||||
semantic gaps: invalid-type exception mapping, bulk update, content deletion,
|
||||
change-token conflict handling, copy/create-from-source, and one range
|
||||
classification warning. See
|
||||
`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`.
|
||||
Evidence update: the 2026-05-13 WP-0014 OpenCMIS pass completed the selected
|
||||
Browser Binding `repository-type` and `object-content` baseline. The latest
|
||||
run, `run-20260513T210255Z`, reports `0` unexpected findings:
|
||||
`repository-type` is warning-only because the local harness uses HTTP, and
|
||||
`object-content` is warning-only because `appendContentStream()` is not
|
||||
supported. See
|
||||
`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md`.
|
||||
|
||||
The score below remains a product-depth estimate against mature CMIS products.
|
||||
The selected OpenCMIS baseline still exits as `infrastructure_error`, but the
|
||||
failure frontier is now narrow and capability-specific rather than basic
|
||||
Browser Binding shape or navigation.
|
||||
The selected OpenCMIS baseline is now stable preparation evidence for
|
||||
repository/type and object/content services, not a full CMIS certification.
|
||||
|
||||
Status: baseline scorecard for the current Browser Binding subset.
|
||||
|
||||
@@ -74,58 +72,56 @@ CMIS interoperability importance rather than engine-internal importance.
|
||||
|
||||
| Metric | Score |
|
||||
| --- | ---: |
|
||||
| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 51% |
|
||||
| Controlled-client Browser Binding usefulness | 70% |
|
||||
| Broad commodity CMIS client compatibility | 46% |
|
||||
| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 59% |
|
||||
| Controlled-client Browser Binding usefulness | 80% |
|
||||
| Broad commodity CMIS client compatibility | 54% |
|
||||
|
||||
Interpretation: the current CMIS layer is a credible Browser Binding subset for
|
||||
known clients and profile-specific integrations. It is not yet a broad ECM/CMIS
|
||||
replacement surface.
|
||||
known clients and profile-specific integrations, especially around repository,
|
||||
type, object, folder, content, move/copy, and controlled mutation workflows. It
|
||||
is not yet a broad ECM/CMIS replacement surface.
|
||||
|
||||
## Capability Scorecard
|
||||
|
||||
| CMIS capability area | Weight | Current depth | Most worthy contender | Gap basis behind the percentage |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| Repository service and repository info | 5 | 82% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.<br>- Unsupported feature catalog exists.<br>- OpenCMIS `repository-type` is warning-only, with the remaining warning caused by local HTTP. |
|
||||
| Type definitions | 6 | 52% | Hyland Alfresco ACS | - Base types and nullable content stream properties exist.<br>- No mutable types or custom schema/type management.<br>- Property definition depth remains intentionally narrow. |
|
||||
| Navigation service | 8 | 58% | Hyland Alfresco ACS | - Root and folder-scoped children, path lookup, folder parent lookup, and parent path segments work.<br>- Projection-only parents exist.<br>- Missing `getDescendants`, `getFolderTree`, and real filing mutations. |
|
||||
| Object read service | 10 | 78% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, allowable actions, property filters, and path-addressed Browser Binding reads exist.<br>- Deleted/hidden objects are now correctly not exposed.<br>- Remaining read-side gaps are mostly around optional services and exception shape. |
|
||||
| Object write service | 8 | 58% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set, and delete-request lifecycle exist.<br>- No bulk update, copy/create-from-source, broad filing mutation, or physical delete semantics.<br>- Delete is intentionally governed, not raw repository removal. |
|
||||
| Content stream read/write | 8 | 74% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, no-content compatibility streams, and partial body slicing exist.<br>- Digest verification and governed access exist.<br>- Remaining gaps are delete-content semantics, append semantics, change-token conflicts, and one range classification warning. |
|
||||
| Repository service and repository info | 5 | 86% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.<br>- Unsupported feature catalog exists.<br>- OpenCMIS `repository-type` completes with only the local HTTP warning. |
|
||||
| Type definitions | 6 | 55% | Hyland Alfresco ACS | - Base types, Browser Binding type definitions, secondary type projection, and nullable content stream properties exist.<br>- No mutable types or custom schema/type management.<br>- Property definition depth remains intentionally narrow. |
|
||||
| Navigation service | 8 | 62% | Hyland Alfresco ACS | - Root and folder-scoped children, path lookup, folder parent lookup, parent path segments, move, and delete-tree work in the selected baseline.<br>- Projection-only parents exist.<br>- Missing `getDescendants`, `getFolderTree`, and real filing mutations. |
|
||||
| Object read service | 10 | 84% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, allowable actions, property filters, and path-addressed Browser Binding reads exist.<br>- Deleted/hidden objects are correctly not exposed.<br>- OpenCMIS object/content read-side baseline now completes with warnings only. |
|
||||
| Object write service | 8 | 70% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set/delete, `bulkUpdateProperties`, and `createDocumentFromSource` exist.<br>- No broad filing mutation, raw physical delete, checkout/checkin, or policy/item creation.<br>- Delete remains intentionally governed, not raw repository removal. |
|
||||
| Content stream read/write | 8 | 82% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, no-content compatibility streams, content tombstones, partial body slicing, and offset-zero full-stream classification exist.<br>- Digest verification and governed access exist.<br>- Remaining warning is unsupported append semantics. |
|
||||
| Versioning service | 8 | 25% | Hyland Alfresco ACS | - Version properties can be projected from engine versions.<br>- No checkout/checkin/cancelCheckout/PWC services.<br>- No version history route or all-versions query behavior. |
|
||||
| Discovery/query | 8 | 25% | Hyland Alfresco ACS | - Narrow document select subset exists.<br>- Unsupported joins/order-by return diagnostics.<br>- Missing CMIS SQL predicates, type joins, full-text, ordering, and rich projection rules. |
|
||||
| Relationships | 5 | 60% | Hyland Alfresco ACS | - Relationship object projection and source filtering exist.<br>- Visibility gates prevent protected relationship leakage.<br>- Missing full relationship service filters, relationship creation through CMIS, and type hierarchy maturity. |
|
||||
| ACL service | 6 | 35% | Hyland Alfresco ACS | - Discover-only ACL projection exists.<br>- `applyACL` is blocked as not implemented.<br>- Missing inherited/direct ACL fidelity, propagation, ACL mutation, and repository principal model. |
|
||||
| Policy service | 3 | 10% | Hyland Alfresco ACS | - Native policy decisions govern exposure.<br>- No CMIS policy objects, `applyPolicy`, `removePolicy`, or `getAppliedPolicies` service surface.<br>- Explicitly unsupported. |
|
||||
| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.<br>- Missing CMIS update-conflict behavior for reused change tokens and richer change event typing.<br>- Change-token maturity is now directly visible in OpenCMIS object/content. |
|
||||
| Change log | 5 | 60% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.<br>- CMIS change-token conflicts are now enforced for property/content mutations.<br>- Missing richer change event typing and broader token semantics across optional services. |
|
||||
| Multi-filing and unfiling | 4 | 25% | Hyland Alfresco ACS | - Projection-only parent maps exist and are useful for navigation.<br>- Standard CMIS `capabilityMultifiling` is correctly false.<br>- No add/remove filing mutations or canonical folder membership model. |
|
||||
| Renditions | 3 | 15% | Hyland Alfresco ACS | - Native representations could become rendition candidates later.<br>- CMIS rendition capability is currently `none`.<br>- No rendition taxonomy or rendition stream routes. |
|
||||
| Retention and hold | 2 | 5% | OpenText / Hyland governance stacks | - Native governance metadata can represent intent later.<br>- No CMIS retention/hold model or mutation services.<br>- Explicitly unsupported. |
|
||||
| Bulk update | 2 | 5% | Hyland Alfresco ACS | - Native batch/error envelopes exist elsewhere in the engine.<br>- No CMIS `bulkUpdateProperties` behavior.<br>- Explicitly unsupported. |
|
||||
| Browser Binding protocol fidelity | 7 | 66% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.<br>- Optional actions and CMIS exception mapping remain incomplete.<br>- Route-level CMIS tests run under the service extras and OpenCMIS now exercises object/content deeply. |
|
||||
| Bulk update | 2 | 65% | Hyland Alfresco ACS | - `bulkUpdateProperties` works for the `compat-tck` profile by batching existing property updates with change-token handling.<br>- It is intentionally narrow and not enabled on the normal governed-authoring profile yet.<br>- No advanced partial-success envelope beyond the Browser Binding response list. |
|
||||
| Browser Binding protocol fidelity | 7 | 78% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, CMIS exception names, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.<br>- Selected OpenCMIS Browser Binding repository/type and object/content baseline completes with warnings only.<br>- Optional services and broader CMIS SQL/versioning protocol surfaces remain incomplete. |
|
||||
| AtomPub binding | 2 | 0% | Hyland Alfresco ACS | - No AtomPub/XML service document or feeds.<br>- Intentionally deferred until monetized need. |
|
||||
| Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.<br>- Intentionally deferred until monetized need. |
|
||||
| External conformance evidence | 3 | 58% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.<br>- Selected `repository-type` baseline is warning-only.<br>- `object-content` executes concrete CRUD/content cases and is now blocked by a short list of semantic gaps rather than startup, path, paging, or basic content-read failures. |
|
||||
| External conformance evidence | 3 | 82% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.<br>- Selected `repository-type` and `object-content` baselines complete with warnings only.<br>- Evidence still covers a selected baseline, not the full OpenCMIS TCK surface. |
|
||||
|
||||
Weighted result from this table: **51%**.
|
||||
Weighted result from this table: **59%**.
|
||||
|
||||
## Most Important Gaps
|
||||
|
||||
1. **External conformance expansion**
|
||||
- Keep the selected OpenCMIS TCK baseline running against `compat-tck`.
|
||||
- Close or explicitly waive the remaining object/content gaps: exception
|
||||
mapping, bulk update, delete content, change-token conflicts, copy, and
|
||||
offset-zero range classification.
|
||||
- Expand selected groups after the supported object/content baseline is
|
||||
stable.
|
||||
- Treat the current repository/type and object/content warnings as known:
|
||||
local HTTP and unsupported append.
|
||||
- Expand selected groups after the supported baseline remains stable.
|
||||
|
||||
2. **Browser Binding fidelity**
|
||||
- Align route/action/selector shapes more closely with CMIS Browser Binding.
|
||||
- Add non-skipped FastAPI route tests in CI with service extras installed.
|
||||
- Add client smoke tests with Apache Chemistry/OpenCMIS where feasible.
|
||||
- Return CMIS-specific exception classes/statuses instead of generic runtime
|
||||
exceptions where OpenCMIS distinguishes invalid argument, constraint, and
|
||||
update conflict.
|
||||
- Continue returning CMIS-specific exception classes/statuses as more
|
||||
optional services are added.
|
||||
|
||||
3. **Query depth**
|
||||
- Add a real CMIS SQL subset parser instead of a two-query allowlist.
|
||||
@@ -146,6 +142,11 @@ Weighted result from this table: **51%**.
|
||||
- Map selected derived representations to CMIS renditions only after we have
|
||||
stable representation taxonomy and real preview/thumbnail use cases.
|
||||
|
||||
7. **Append streams**
|
||||
- Keep `appendContentStream()` unsupported unless a real client requires it.
|
||||
- If implemented later, design it through blob composition/deduplication
|
||||
rather than byte concatenation in the CMIS adapter.
|
||||
|
||||
## Product Positioning Takeaway
|
||||
|
||||
Against a mature CMIS implementation such as Hyland Alfresco ACS, Kontextual is
|
||||
|
||||
75
docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md
Normal file
75
docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# CMIS OpenCMIS TCK Evidence - WP-0014 - 2026-05-13T21:02:55Z
|
||||
|
||||
## Run Summary
|
||||
|
||||
- Run ID: `run-20260513T210255Z`
|
||||
- Harness: `guide-board` with `open-cmis-tck` extension
|
||||
- Assessment: `cmis-browser-baseline`
|
||||
- Target: `kontextual-cmis-compat`
|
||||
- Endpoint: `http://127.0.0.1:8010/cmis/compat-tck/browser`
|
||||
- OpenCMIS TCK: CMIS 1.1.0, revision `1789681`
|
||||
- Result: `completed`
|
||||
- Policy: `0` unexpected findings, `0` applied waivers
|
||||
- Internal verification: `.venv/bin/python -m pytest -q` -> `165 passed`,
|
||||
`14 skipped`
|
||||
|
||||
The previous unpersisted `/tmp` assessment output was not available anymore,
|
||||
so this run is the persisted evidence baseline for the remaining WP-0014 CMIS
|
||||
object/content maturity blockers.
|
||||
|
||||
## Normalized Results
|
||||
|
||||
| Group | Result | Counts | Remaining non-green findings |
|
||||
| --- | --- | --- | --- |
|
||||
| `repository-type` | `warning` | `38 pass`, `2 info`, `1 skipped`, `1 warning` | Local test endpoint uses HTTP rather than HTTPS. |
|
||||
| `object-content` | `warning` | `10 info`, `5 skipped`, `1 warning` | `appendContentStream()` is not supported. |
|
||||
|
||||
Guide Board summary:
|
||||
|
||||
- `pass`: 1
|
||||
- `warning`: 2
|
||||
|
||||
## Blocker Resolution
|
||||
|
||||
Resolved in this pass:
|
||||
|
||||
- CMIS-specific exception mapping now returns Browser Binding exception names
|
||||
such as `invalidArgument`, `constraint`, `updateConflict`, `notSupported`,
|
||||
`objectNotFound`, and `permissionDenied` instead of leaking generic `422`
|
||||
diagnostics into CMIS clients.
|
||||
- `bulkUpdateProperties` is implemented for the `compat-tck` Browser Binding
|
||||
profile by applying the existing CMIS `updateProperties` path to each
|
||||
requested object with per-object change-token handling.
|
||||
- `deleteContentStream` now records a content-change version, tombstones the
|
||||
content projection, clears content-stream properties, and returns CMIS
|
||||
`constraint` for subsequent stream reads so OpenCMIS resolves the stream to
|
||||
`null` rather than treating the document as deleted.
|
||||
- Change-token conflicts are enforced for CMIS property and content mutations.
|
||||
- `createDocumentFromSource`/copy is implemented by creating a governed asset
|
||||
copy that reuses representation blob references instead of duplicating bytes.
|
||||
- Offset-zero content range requests are classified as full-stream responses
|
||||
unless a non-zero offset or explicit length is requested.
|
||||
|
||||
## Remaining Scope
|
||||
|
||||
Remaining warning-only items:
|
||||
|
||||
- `appendContentStream()` is unsupported by design for now. The engine supports
|
||||
whole-stream replacement through deduplicated content services; append would
|
||||
need a deliberate blob-composition design before being advertised.
|
||||
- HTTPS is not used in the local harness endpoint. This is deployment/test
|
||||
topology, not a CMIS adapter behavior gap.
|
||||
|
||||
Skipped OpenCMIS object/content cases are aligned with declared capability
|
||||
boundaries:
|
||||
|
||||
- Relationship, policy, and item creation are not advertised as creatable.
|
||||
- Folder-name change-token subcases are skipped by the TCK.
|
||||
|
||||
## Interpretation
|
||||
|
||||
The selected Browser Binding baseline is now stable enough to be considered a
|
||||
completed compatibility-preparation baseline for repository/type and
|
||||
object/content services. This is not full CMIS 1.1 certification and does not
|
||||
cover AtomPub, Web Services, versioning/PWC, full query, renditions, retention,
|
||||
or policy mutation depth.
|
||||
@@ -20,6 +20,7 @@ from kontextual_engine.core import (
|
||||
Actor,
|
||||
ActorType,
|
||||
AssetRepresentation,
|
||||
AssetVersion,
|
||||
AuditEvent,
|
||||
AuditOutcome,
|
||||
Classification,
|
||||
@@ -42,6 +43,7 @@ from kontextual_engine.core import (
|
||||
RetrievalFeedbackLabel,
|
||||
SourceReference,
|
||||
TransformationRunStatus,
|
||||
VersionChangeType,
|
||||
WorkflowExceptionKind,
|
||||
WorkflowExceptionStatus,
|
||||
WorkflowInputDefinition,
|
||||
@@ -734,7 +736,17 @@ class ServiceRuntime:
|
||||
"CMIS object not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
if asset.metadata.get("cmis_content_deleted") or not self._cmis_asset_representations(asset):
|
||||
if asset.metadata.get("cmis_content_deleted"):
|
||||
raise ValidationError(
|
||||
"CMIS document has no content stream",
|
||||
details={
|
||||
"code": "cmis.no_content_stream",
|
||||
"cmis_exception": "constraint",
|
||||
"object_id": object_id,
|
||||
"access_point_id": access_point_id,
|
||||
},
|
||||
)
|
||||
if not self._cmis_asset_representations(asset):
|
||||
digest = content_digest(b"")
|
||||
representation = AssetRepresentation(
|
||||
asset_id=asset_id,
|
||||
@@ -984,6 +996,206 @@ class ServiceRuntime:
|
||||
payload["folder_id"] = parent_folder_id
|
||||
return cmis_browser_object(self.cmis_create_document(access_point_id, payload, context))
|
||||
|
||||
def cmis_create_document_from_source(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.CREATE_DOCUMENT, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "createDocumentFromSource")
|
||||
properties = _cmis_browser_properties(payload)
|
||||
type_id = properties.get("cmis:objectTypeId")
|
||||
if type_id and type_id not in {CMISBaseType.DOCUMENT.value, "kontextual:document"}:
|
||||
raise ValidationError(
|
||||
"Invalid CMIS source-copy document type",
|
||||
details={"operation": "createDocumentFromSource", "type_id": type_id},
|
||||
)
|
||||
source_object_id = payload.get("sourceId") or payload.get("source_id") or payload.get("sourceObjectId")
|
||||
if not source_object_id:
|
||||
raise ValidationError(
|
||||
"CMIS source object id is required",
|
||||
details={"operation": "createDocumentFromSource", "parameter": "sourceId"},
|
||||
)
|
||||
source_asset_id = _cmis_asset_id(str(source_object_id))
|
||||
source_asset = self.repository.get_asset(source_asset_id)
|
||||
if not mapper.access_point.exposes_asset(source_asset, context):
|
||||
raise NotFoundError(
|
||||
"CMIS source object not found",
|
||||
details={"object_id": source_object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
name = str(properties.get("cmis:name") or payload.get("name") or source_asset.title).strip()
|
||||
if not name:
|
||||
raise ValidationError("CMIS name cannot be empty", details={"operation": "createDocumentFromSource"})
|
||||
asset_id = payload.get("asset_id") or new_id("asset")
|
||||
representations = [
|
||||
replace(
|
||||
representation,
|
||||
asset_id=asset_id,
|
||||
representation_id=new_id("repr"),
|
||||
producer="cmis-createDocumentFromSource",
|
||||
metadata={
|
||||
**representation.metadata,
|
||||
"cmis_source_object_id": str(source_object_id),
|
||||
"cmis_source_representation_id": representation.representation_id,
|
||||
},
|
||||
)
|
||||
for representation in self._cmis_asset_representations(source_asset)
|
||||
]
|
||||
result = self.asset_service().create_asset(
|
||||
name,
|
||||
source_asset.classification,
|
||||
context,
|
||||
asset_id=asset_id,
|
||||
source_refs=list(source_asset.source_refs),
|
||||
representations=representations,
|
||||
idempotency_key=payload.get("idempotency_key"),
|
||||
)
|
||||
metadata = {
|
||||
key: value
|
||||
for key, value in source_asset.metadata.items()
|
||||
if key not in {"cmis_path", "cmis_paths", "cmis_parent_folder_id", "cmis_content_deleted"}
|
||||
}
|
||||
metadata.update(
|
||||
{
|
||||
"cmis_copied_from_object_id": str(source_object_id),
|
||||
"cmis_copied_from_asset_id": source_asset.id,
|
||||
"file_name": name,
|
||||
}
|
||||
)
|
||||
if "cmis:description" in properties:
|
||||
metadata["description"] = str(properties.get("cmis:description") or "")
|
||||
if "cmis:secondaryObjectTypeIds" in properties:
|
||||
metadata["cmis_secondary_object_type_ids"] = _cmis_value_list(properties.get("cmis:secondaryObjectTypeIds"))
|
||||
folder_id = payload.get("folder_id") or payload.get("folderId")
|
||||
if folder_id:
|
||||
folder_path = _cmis_folder_path(folder_id) or "/"
|
||||
metadata["cmis_path"] = _normalize_cmis_path(f"{folder_path}/{name}")
|
||||
metadata["cmis_parent_folder_id"] = "cmis-root" if folder_path == "/" else mapper.folder_object_id(folder_path)
|
||||
self.repository.save_asset(replace(result.asset, metadata=metadata))
|
||||
return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context)
|
||||
|
||||
def cmis_browser_create_document_from_source(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = dict(payload)
|
||||
if parent_folder_id and "folder_id" not in payload and "folderId" not in payload:
|
||||
payload["folder_id"] = parent_folder_id
|
||||
return cmis_browser_object(self.cmis_create_document_from_source(access_point_id, payload, context))
|
||||
|
||||
def cmis_bulk_update_properties(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
) -> list[dict[str, Any]]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.BULK_UPDATE_PROPERTIES, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "bulkUpdateProperties")
|
||||
entries = _cmis_browser_bulk_entries(payload)
|
||||
if not entries:
|
||||
raise ValidationError(
|
||||
"CMIS bulk update object ids are required",
|
||||
details={"operation": "bulkUpdateProperties", "parameter": "objectId"},
|
||||
)
|
||||
properties = _cmis_browser_properties(payload)
|
||||
if not properties:
|
||||
raise ValidationError(
|
||||
"CMIS bulk update properties are required",
|
||||
details={"operation": "bulkUpdateProperties", "parameter": "propertyId"},
|
||||
)
|
||||
updated_entries: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
update_payload: dict[str, Any] = {"properties": dict(properties)}
|
||||
if entry.get("change_token"):
|
||||
update_payload["expected_current_version_id"] = entry["change_token"]
|
||||
updated = self.cmis_update_properties(access_point_id, entry["object_id"], update_payload, context)
|
||||
updated_properties = updated.get("properties", {})
|
||||
object_id_property = updated_properties.get("cmis:objectId")
|
||||
change_token_property = updated_properties.get("cmis:changeToken")
|
||||
updated_entries.append(
|
||||
{
|
||||
"id": entry["object_id"],
|
||||
"newId": object_id_property,
|
||||
"changeToken": change_token_property,
|
||||
}
|
||||
)
|
||||
return updated_entries
|
||||
|
||||
def _cmis_expected_change_token(self, payload: dict[str, Any], properties: dict[str, Any]) -> str | None:
|
||||
return properties.pop(
|
||||
"expected_current_version_id",
|
||||
payload.get("expected_current_version_id") or payload.get("changeToken") or payload.get("change_token"),
|
||||
)
|
||||
|
||||
def _cmis_assert_change_token(
|
||||
self,
|
||||
asset,
|
||||
expected_current_version_id: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
) -> None:
|
||||
if not expected_current_version_id:
|
||||
return
|
||||
if asset.current_version_id != expected_current_version_id:
|
||||
raise ValidationError(
|
||||
"CMIS change token conflict",
|
||||
details={
|
||||
"code": "asset.version_conflict",
|
||||
"cmis_exception": "updateConflict",
|
||||
"operation": operation,
|
||||
"asset_id": asset.id,
|
||||
"expected_current_version_id": expected_current_version_id,
|
||||
"current_version_id": asset.current_version_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _cmis_record_asset_version(
|
||||
self,
|
||||
asset,
|
||||
context: OperationContext,
|
||||
*,
|
||||
change_type: VersionChangeType,
|
||||
operation_id: str,
|
||||
metadata_delta: dict[str, Any] | None = None,
|
||||
representation_ids: tuple[str, ...] = (),
|
||||
):
|
||||
versions = self.repository.list_versions(asset.id)
|
||||
next_sequence = max((version.sequence for version in versions), default=0) + 1
|
||||
version = AssetVersion(
|
||||
asset_id=asset.id,
|
||||
sequence=next_sequence,
|
||||
change_type=change_type,
|
||||
representation_ids=representation_ids,
|
||||
actor_id=context.actor.id,
|
||||
operation_id=operation_id,
|
||||
parent_version_id=asset.current_version_id,
|
||||
metadata_delta=dict(metadata_delta or {}),
|
||||
lifecycle=asset.lifecycle.value,
|
||||
)
|
||||
updated = asset.with_current_version(version.version_id)
|
||||
self.repository.save_actor(context.actor)
|
||||
self.repository.save_asset(updated)
|
||||
self.repository.save_version(version)
|
||||
self.repository.save_audit_event(
|
||||
AuditEvent.from_context(
|
||||
operation_id,
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
details={"version_id": version.version_id},
|
||||
)
|
||||
)
|
||||
return updated
|
||||
|
||||
def cmis_update_properties(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -996,7 +1208,7 @@ class ServiceRuntime:
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "updateProperties")
|
||||
properties = dict(payload.get("properties", payload))
|
||||
expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None))
|
||||
expected = self._cmis_expected_change_token(payload, properties)
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
return self._cmis_update_workspace_folder(mapper, object_id, properties, context)
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
@@ -1038,6 +1250,17 @@ class ServiceRuntime:
|
||||
expected = None
|
||||
if asset_metadata_updates or title_update is not None:
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
self._cmis_assert_change_token(asset, expected, operation="updateProperties")
|
||||
metadata_delta = dict(asset_metadata_updates)
|
||||
if title_update is not None:
|
||||
metadata_delta["title"] = title_update
|
||||
asset = self._cmis_record_asset_version(
|
||||
asset,
|
||||
context,
|
||||
change_type=VersionChangeType.METADATA_CHANGED,
|
||||
operation_id="cmis.updateProperties",
|
||||
metadata_delta=metadata_delta,
|
||||
)
|
||||
metadata = {**asset.metadata, **asset_metadata_updates}
|
||||
if title_update is not None and asset.metadata.get("cmis_path"):
|
||||
current_path = _normalize_cmis_path(str(asset.metadata["cmis_path"]))
|
||||
@@ -1061,6 +1284,11 @@ class ServiceRuntime:
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "setContentStream")
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
expected = (
|
||||
payload.get("expected_current_version_id")
|
||||
or payload.get("changeToken")
|
||||
or payload.get("change_token")
|
||||
)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
if asset.metadata.get("cmis_content_deleted"):
|
||||
metadata = dict(asset.metadata)
|
||||
@@ -1072,7 +1300,7 @@ class ServiceRuntime:
|
||||
_cmis_media_type(payload.get("media_type", "text/plain")),
|
||||
payload.get("content", ""),
|
||||
context,
|
||||
expected_current_version_id=payload.get("expected_current_version_id"),
|
||||
expected_current_version_id=expected,
|
||||
metadata={"cmis": {"operation": "setContentStream"}},
|
||||
)
|
||||
return self.cmis_object(access_point_id, object_id, context)
|
||||
@@ -1082,11 +1310,13 @@ class ServiceRuntime:
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.SET_CONTENT_STREAM, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "deleteContentStream")
|
||||
payload = payload or {}
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
@@ -1094,6 +1324,20 @@ class ServiceRuntime:
|
||||
"CMIS object not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
expected = (
|
||||
payload.get("expected_current_version_id")
|
||||
or payload.get("changeToken")
|
||||
or payload.get("change_token")
|
||||
)
|
||||
self._cmis_assert_change_token(asset, expected, operation="deleteContentStream")
|
||||
asset = self._cmis_record_asset_version(
|
||||
asset,
|
||||
context,
|
||||
change_type=VersionChangeType.CONTENT_CHANGED,
|
||||
operation_id="cmis.deleteContentStream",
|
||||
metadata_delta={"cmis_content_deleted": True},
|
||||
representation_ids=tuple(representation.representation_id for representation in self._cmis_asset_representations(asset)),
|
||||
)
|
||||
self.repository.save_asset(replace(asset, metadata={**asset.metadata, "cmis_content_deleted": True}))
|
||||
return self.cmis_object(access_point_id, object_id, context)
|
||||
|
||||
@@ -3067,22 +3311,86 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
)
|
||||
app.state.kontextual_runtime = runtime
|
||||
|
||||
def _is_cmis_request(request: Request) -> bool:
|
||||
return str(request.url.path).startswith("/cmis/")
|
||||
|
||||
def _cmis_error_response(status_code: int, payload: dict[str, Any]) -> JSONResponse:
|
||||
details = dict(payload.get("details", {})) if isinstance(payload.get("details"), dict) else {}
|
||||
message = str(payload.get("message") or payload.get("detail") or "CMIS request failed")
|
||||
code = str(details.get("code") or payload.get("code") or "kontextual.cmis")
|
||||
cmis_exception = details.get("cmis_exception")
|
||||
resolved_status = status_code
|
||||
if not cmis_exception:
|
||||
lowered = message.lower()
|
||||
if code == "kontextual.not_found" or status_code == 404:
|
||||
cmis_exception = "objectNotFound"
|
||||
resolved_status = 404
|
||||
elif code == "kontextual.authorization" or status_code == 403:
|
||||
cmis_exception = "permissionDenied"
|
||||
resolved_status = 403
|
||||
elif code == "asset.version_conflict":
|
||||
cmis_exception = "updateConflict"
|
||||
resolved_status = 409
|
||||
elif code == "cmis.not_supported" or lowered.startswith("unsupported cmis browser binding action"):
|
||||
cmis_exception = "notSupported"
|
||||
resolved_status = 405
|
||||
elif "path already exists" in lowered or "cannot be moved" in lowered:
|
||||
cmis_exception = "constraint"
|
||||
resolved_status = 409
|
||||
else:
|
||||
cmis_exception = "invalidArgument"
|
||||
resolved_status = 400
|
||||
elif cmis_exception == "objectNotFound":
|
||||
resolved_status = 404
|
||||
elif cmis_exception == "permissionDenied":
|
||||
resolved_status = 403
|
||||
elif cmis_exception == "updateConflict":
|
||||
resolved_status = 409
|
||||
elif cmis_exception == "notSupported":
|
||||
resolved_status = 405
|
||||
elif cmis_exception == "constraint":
|
||||
resolved_status = 409
|
||||
elif status_code == 422:
|
||||
resolved_status = 400
|
||||
content = {
|
||||
"exception": cmis_exception,
|
||||
"message": message,
|
||||
"code": code,
|
||||
"details": details,
|
||||
}
|
||||
return JSONResponse(status_code=resolved_status, content=content)
|
||||
|
||||
@app.exception_handler(NotFoundError)
|
||||
async def not_found_error_handler(_request, exc: NotFoundError) -> JSONResponse:
|
||||
if _is_cmis_request(_request):
|
||||
return _cmis_error_response(404, _error_payload(exc))
|
||||
return JSONResponse(status_code=404, content=_error_payload(exc))
|
||||
|
||||
@app.exception_handler(AuthorizationError)
|
||||
async def authorization_error_handler(_request, exc: AuthorizationError) -> JSONResponse:
|
||||
if _is_cmis_request(_request):
|
||||
return _cmis_error_response(403, _authorization_error_payload(exc))
|
||||
return JSONResponse(status_code=403, content=_authorization_error_payload(exc))
|
||||
|
||||
@app.exception_handler(ValidationError)
|
||||
async def validation_error_handler(_request, exc: ValidationError) -> JSONResponse:
|
||||
if _is_cmis_request(_request):
|
||||
return _cmis_error_response(422, _error_payload(exc))
|
||||
return JSONResponse(status_code=422, content=_error_payload(exc))
|
||||
|
||||
@app.exception_handler(KontextualError)
|
||||
async def kontextual_error_handler(_request, exc: KontextualError) -> JSONResponse:
|
||||
if _is_cmis_request(_request):
|
||||
return _cmis_error_response(400, _error_payload(exc))
|
||||
return JSONResponse(status_code=400, content=_error_payload(exc))
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_request, exc: HTTPException) -> JSONResponse:
|
||||
if _is_cmis_request(_request):
|
||||
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
|
||||
return _cmis_error_response(exc.status_code, detail)
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
@app.get("/health", tags=["system"])
|
||||
def health() -> dict[str, Any]:
|
||||
return runtime.health()
|
||||
@@ -3208,7 +3516,7 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
length = max(end - offset + 1, 0)
|
||||
start = max(offset or 0, 0)
|
||||
requested_length = None if length is None else max(length, 0)
|
||||
is_partial = offset is not None or length is not None or bool(range_header)
|
||||
is_partial = start > 0 or requested_length is not None
|
||||
content_length = max(representation.size_bytes - start, 0)
|
||||
if requested_length is not None:
|
||||
content_length = min(content_length, requested_length)
|
||||
@@ -3337,6 +3645,14 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action in {"createDocumentFromSource", "copy"}:
|
||||
return response(
|
||||
runtime.cmis_browser_create_document_from_source,
|
||||
access_point_id,
|
||||
payload,
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action in {"delete", "deleteObject"}:
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteObject"})
|
||||
@@ -3363,8 +3679,10 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
if action in {"deleteContent", "deleteContentStream"}:
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteContentStream"})
|
||||
response(runtime.cmis_delete_content_stream, access_point_id, object_id, context)
|
||||
response(runtime.cmis_delete_content_stream, access_point_id, object_id, context, payload)
|
||||
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
|
||||
if action in {"bulkUpdateProperties", "bulkUpdate"}:
|
||||
return response(runtime.cmis_bulk_update_properties, access_point_id, payload, context)
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS Browser Binding action",
|
||||
details={
|
||||
@@ -3372,6 +3690,7 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
"supported": [
|
||||
"createFolder",
|
||||
"createDocument",
|
||||
"createDocumentFromSource",
|
||||
"delete",
|
||||
"deleteObject",
|
||||
"deleteTree",
|
||||
@@ -4408,6 +4727,28 @@ def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return properties
|
||||
|
||||
|
||||
def _cmis_browser_bulk_entries(payload: dict[str, Any]) -> list[dict[str, str | None]]:
|
||||
object_ids: dict[str, str] = {}
|
||||
change_tokens: dict[str, str | None] = {}
|
||||
for key, value in payload.items():
|
||||
if key.startswith("objectId["):
|
||||
index = key[len("objectId[") :].split("]", 1)[0]
|
||||
object_ids[index] = str(value)
|
||||
elif key.startswith("changeToken["):
|
||||
index = key[len("changeToken[") :].split("]", 1)[0]
|
||||
token = str(value)
|
||||
change_tokens[index] = token or None
|
||||
|
||||
def sort_key(index: str) -> tuple[int, str]:
|
||||
return (int(index), index) if index.isdigit() else (10_000, index)
|
||||
|
||||
return [
|
||||
{"object_id": object_ids[index], "change_token": change_tokens.get(index)}
|
||||
for index in sorted(object_ids, key=sort_key)
|
||||
if object_ids[index]
|
||||
]
|
||||
|
||||
|
||||
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
|
||||
return AuthorizationError(
|
||||
"CMIS operation denied by access-point profile",
|
||||
|
||||
@@ -117,6 +117,7 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
|
||||
CMISAction.DELETE_OBJECT,
|
||||
CMISAction.DELETE_TREE,
|
||||
CMISAction.SET_CONTENT_STREAM,
|
||||
CMISAction.BULK_UPDATE_PROPERTIES,
|
||||
}
|
||||
)
|
||||
MUTATION_ACTIONS = {
|
||||
@@ -304,6 +305,7 @@ class CMISAccessProfile:
|
||||
+ (
|
||||
CMISCapability.OBJECT_WRITE,
|
||||
CMISCapability.CONTENT_STREAM_WRITE,
|
||||
CMISCapability.BULK_UPDATE,
|
||||
),
|
||||
allow_mutations=True,
|
||||
metadata={"compatibility": "selected-opencmis-tck-browser-subset"},
|
||||
|
||||
@@ -59,6 +59,13 @@ def test_governed_authoring_profile_allows_selected_write_actions() -> None:
|
||||
assert profile.decide_action(CMISAction.APPLY_ACL, context).reason == "cmis_operation_not_implemented"
|
||||
|
||||
|
||||
def test_compat_tck_profile_allows_bulk_update_for_browser_binding_maturity() -> None:
|
||||
profile = CMISAccessProfile.compat_tck()
|
||||
context = _context()
|
||||
|
||||
assert profile.decide_action(CMISAction.BULK_UPDATE_PROPERTIES, context).allowed is True
|
||||
|
||||
|
||||
def test_profiles_hide_denied_sensitivities_without_partial_exposure() -> None:
|
||||
profile = CMISAccessProfile.readonly_browser()
|
||||
context = _context()
|
||||
|
||||
@@ -213,8 +213,9 @@ def test_cmis_query_reports_unsupported_subset_diagnostics(cmis_client) -> None:
|
||||
params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["details"]["supported"] == [
|
||||
assert response.status_code == 400
|
||||
assert response.json()["exception"] == "invalidArgument"
|
||||
assert response.json()["details"]["supported"] == [
|
||||
"SELECT * FROM cmis:document",
|
||||
"SELECT * FROM kontextual:document",
|
||||
]
|
||||
@@ -245,6 +246,10 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
|
||||
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
|
||||
params={"offset": 2, "length": 4},
|
||||
)
|
||||
byte_offset_zero = cmis_client.get(
|
||||
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
|
||||
params={"offset": 0},
|
||||
)
|
||||
deleted = cmis_client.post(
|
||||
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/delete",
|
||||
json={},
|
||||
@@ -258,6 +263,9 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
|
||||
assert byte_stream.headers["etag"].startswith("sha256:")
|
||||
assert byte_range.content == b"Upda"
|
||||
assert byte_range.headers["content-length"] == "4"
|
||||
assert byte_offset_zero.status_code == 200
|
||||
assert byte_offset_zero.content == b"# Updated"
|
||||
assert "content-range" not in byte_offset_zero.headers
|
||||
assert deleted.json()["lifecycle"] == "delete_requested"
|
||||
|
||||
|
||||
@@ -286,8 +294,9 @@ def test_cmis_browser_binding_create_document_validates_type_and_secondary_ids(c
|
||||
files={"content": ("secondary.txt", b"Secondary content", "text/plain")},
|
||||
)
|
||||
|
||||
assert invalid.status_code == 422
|
||||
assert invalid.json()["detail"]["details"]["type_id"] == "cmis:folder"
|
||||
assert invalid.status_code == 400
|
||||
assert invalid.json()["exception"] == "invalidArgument"
|
||||
assert invalid.json()["details"]["type_id"] == "cmis:folder"
|
||||
assert created.status_code == 200
|
||||
assert created.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == ["kontextual:secondary"]
|
||||
|
||||
@@ -326,6 +335,227 @@ def test_cmis_browser_binding_document_without_content_streams_empty_compatibili
|
||||
assert content.headers["content-length"] == "0"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_delete_content_stream_tombstones_content(cmis_client) -> None:
|
||||
document = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Delete Content Document",
|
||||
},
|
||||
files={"content": ("delete-content.txt", b"Delete me", "text/plain")},
|
||||
).json()
|
||||
object_id = document["properties"]["cmis:objectId"]["value"]
|
||||
token = document["properties"]["cmis:changeToken"]["value"]
|
||||
|
||||
deleted = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={"cmisaction": "deleteContentStream", "objectId": object_id, "changeToken": token},
|
||||
)
|
||||
content_after_delete = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "content", "objectId": object_id},
|
||||
)
|
||||
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["properties"]["cmis:contentStreamLength"]["value"] is None
|
||||
assert deleted.json()["properties"]["cmis:changeToken"]["value"] != token
|
||||
assert content_after_delete.status_code == 409
|
||||
assert content_after_delete.json()["exception"] == "constraint"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_change_tokens_conflict_on_stale_updates(cmis_client) -> None:
|
||||
document = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Token Document",
|
||||
},
|
||||
files={"content": ("token.txt", b"Token content", "text/plain")},
|
||||
).json()
|
||||
object_id = document["properties"]["cmis:objectId"]["value"]
|
||||
original_token = document["properties"]["cmis:changeToken"]["value"]
|
||||
renamed = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "updateProperties",
|
||||
"objectId": object_id,
|
||||
"changeToken": original_token,
|
||||
"propertyId[0]": "cmis:name",
|
||||
"propertyValue[0]": "Token Document Renamed",
|
||||
},
|
||||
)
|
||||
renamed_token = renamed.json()["properties"]["cmis:changeToken"]["value"]
|
||||
stale_property_update = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "updateProperties",
|
||||
"objectId": object_id,
|
||||
"changeToken": original_token,
|
||||
"propertyId[0]": "cmis:description",
|
||||
"propertyValue[0]": "stale",
|
||||
},
|
||||
)
|
||||
content_updated = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "setContentStream",
|
||||
"objectId": object_id,
|
||||
"changeToken": renamed_token,
|
||||
"content": "Updated token content",
|
||||
"media_type": "text/plain",
|
||||
},
|
||||
)
|
||||
stale_content_update = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "setContentStream",
|
||||
"objectId": object_id,
|
||||
"changeToken": renamed_token,
|
||||
"content": "Stale update",
|
||||
"media_type": "text/plain",
|
||||
},
|
||||
)
|
||||
|
||||
assert renamed.status_code == 200
|
||||
assert renamed_token != original_token
|
||||
assert stale_property_update.status_code == 409
|
||||
assert stale_property_update.json()["exception"] == "updateConflict"
|
||||
assert content_updated.status_code == 200
|
||||
assert content_updated.json()["properties"]["cmis:changeToken"]["value"] != renamed_token
|
||||
assert stale_content_update.status_code == 409
|
||||
assert stale_content_update.json()["exception"] == "updateConflict"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_create_document_from_source_reuses_content_projection(cmis_client) -> None:
|
||||
source = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Copy Source",
|
||||
},
|
||||
files={"content": ("copy-source.txt", b"Source copy bytes", "text/plain")},
|
||||
).json()
|
||||
folder = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createFolder",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:folder",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Copy Destination",
|
||||
},
|
||||
).json()
|
||||
source_id = source["properties"]["cmis:objectId"]["value"]
|
||||
folder_id = folder["properties"]["cmis:objectId"]["value"]
|
||||
copied = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocumentFromSource",
|
||||
"objectId": folder_id,
|
||||
"sourceId": source_id,
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Copied Document",
|
||||
},
|
||||
)
|
||||
copied_id = copied.json()["properties"]["cmis:objectId"]["value"]
|
||||
copied_content = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "content", "objectId": copied_id},
|
||||
)
|
||||
|
||||
assert copied.status_code == 200
|
||||
assert copied_id != source_id
|
||||
assert copied.json()["properties"]["cmis:name"]["value"] == "Copied Document"
|
||||
assert copied.json()["properties"]["cmis:contentStreamLength"]["value"] == 17
|
||||
assert copied_content.content == b"Source copy bytes"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_bulk_update_renames_documents(cmis_client) -> None:
|
||||
folder_one = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createFolder",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:folder",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Bulk Folder One",
|
||||
},
|
||||
).json()
|
||||
folder_two = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createFolder",
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:folder",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Bulk Folder Two",
|
||||
},
|
||||
).json()
|
||||
first = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"objectId": folder_one["properties"]["cmis:objectId"]["value"],
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Bulk One",
|
||||
},
|
||||
files={"content": ("bulk-one.txt", b"bulk one", "text/plain")},
|
||||
).json()
|
||||
second = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
data={
|
||||
"cmisaction": "createDocument",
|
||||
"objectId": folder_two["properties"]["cmis:objectId"]["value"],
|
||||
"propertyId[0]": "cmis:objectTypeId",
|
||||
"propertyValue[0]": "cmis:document",
|
||||
"propertyId[1]": "cmis:name",
|
||||
"propertyValue[1]": "Bulk Two",
|
||||
},
|
||||
files={"content": ("bulk-two.txt", b"bulk two", "text/plain")},
|
||||
).json()
|
||||
first_id = first["properties"]["cmis:objectId"]["value"]
|
||||
second_id = second["properties"]["cmis:objectId"]["value"]
|
||||
response = cmis_client.post(
|
||||
"/cmis/compat-tck/browser",
|
||||
data={
|
||||
"cmisaction": "bulkUpdate",
|
||||
"objectId[0]": first_id,
|
||||
"changeToken[0]": first["properties"]["cmis:changeToken"]["value"],
|
||||
"objectId[1]": second_id,
|
||||
"changeToken[1]": second["properties"]["cmis:changeToken"]["value"],
|
||||
"propertyId[0]": "cmis:name",
|
||||
"propertyValue[0]": "Bulk Renamed",
|
||||
},
|
||||
)
|
||||
first_after = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "objectId": first_id},
|
||||
).json()
|
||||
second_after = cmis_client.get(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
params={"cmisselector": "object", "objectId": second_id},
|
||||
).json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert {item["id"] for item in response.json()} == {first_id, second_id}
|
||||
assert all(item["changeToken"] for item in response.json())
|
||||
assert first_after["properties"]["cmis:name"]["value"] == "Bulk Renamed"
|
||||
assert second_after["properties"]["cmis:name"]["value"] == "Bulk Renamed"
|
||||
|
||||
|
||||
def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None:
|
||||
created = cmis_client.post(
|
||||
"/cmis/compat-tck/browser/root",
|
||||
@@ -523,9 +753,10 @@ def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis
|
||||
json={"properties": {"cmis:objectTypeId": "cmis:folder"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["details"]["property"] == "cmis:objectTypeId"
|
||||
assert response.json()["detail"]["details"]["supported"] == [
|
||||
assert response.status_code == 400
|
||||
assert response.json()["exception"] == "invalidArgument"
|
||||
assert response.json()["details"]["property"] == "cmis:objectTypeId"
|
||||
assert response.json()["details"]["supported"] == [
|
||||
"cmis:name",
|
||||
"cmis:description",
|
||||
"cmis:secondaryObjectTypeIds",
|
||||
|
||||
@@ -10,7 +10,7 @@ topic_slug: markitect
|
||||
planning_priority: high
|
||||
planning_order: 14
|
||||
created: "2026-05-08"
|
||||
updated: "2026-05-08"
|
||||
updated: "2026-05-13"
|
||||
state_hub_workstream_id: "ccfa90ee-be23-499b-a727-451a0d289df7"
|
||||
---
|
||||
|
||||
@@ -196,6 +196,46 @@ Current external frontier:
|
||||
- `createDocumentFromSource`/copy remains unsupported.
|
||||
- Offset-zero range requests are still marked partial.
|
||||
|
||||
## Implementation Evidence - 2026-05-13T21:02:55Z
|
||||
|
||||
Evidence file:
|
||||
|
||||
- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md`
|
||||
|
||||
Implemented in this pass:
|
||||
|
||||
- CMIS Browser Binding exception mapping for `invalidArgument`, `constraint`,
|
||||
`updateConflict`, `notSupported`, `objectNotFound`, and `permissionDenied`.
|
||||
- `bulkUpdateProperties` for the `compat-tck` profile, using the existing
|
||||
CMIS property update path and per-object change-token checks.
|
||||
- Stronger `deleteContentStream` tombstone semantics with content-change
|
||||
versioning and CMIS `constraint` responses for post-delete stream reads.
|
||||
- Change-token conflict handling for property/content mutations.
|
||||
- `createDocumentFromSource`/copy using representation blob-reference reuse.
|
||||
- Offset-zero range classification as a full-stream response.
|
||||
|
||||
Latest verification:
|
||||
|
||||
- Internal CMIS suite: `.venv/bin/python -m pytest tests/cmis -q`
|
||||
-> `55 passed`.
|
||||
- Full suite: `.venv/bin/python -m pytest -q` -> `165 passed`,
|
||||
`14 skipped`.
|
||||
- OpenCMIS: `run-20260513T210255Z` in
|
||||
`/tmp/open-cmis-tck-kontextual-20260513T230205Z`.
|
||||
- Guide Board status: `completed`, with `0` unexpected findings.
|
||||
- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
|
||||
- `object-content`: `10 info`, `5 skipped`, `1 warning`.
|
||||
|
||||
Current external frontier:
|
||||
|
||||
- The selected OpenCMIS Browser Binding repository/type and object/content
|
||||
baseline is completed with warnings only.
|
||||
- Remaining warnings are local HTTP instead of HTTPS and unsupported
|
||||
`appendContentStream()`.
|
||||
- Further maturity work should move to deliberately selected optional CMIS
|
||||
areas: query depth, descendants/folder-tree read navigation, version-history
|
||||
reads, relationship service depth, ACL detail, and rendition projection.
|
||||
|
||||
## D14.1 - Define CMIS maturity boundary and TCK profile semantics
|
||||
|
||||
```task
|
||||
@@ -277,7 +317,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T005
|
||||
status: in_progress
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5feb6db8-24eb-4c20-8c3e-d530f396ef6a"
|
||||
```
|
||||
@@ -296,8 +336,9 @@ Progress:
|
||||
|
||||
- Done for normal reads, no-content compatibility streams, partial body slicing,
|
||||
`Content-Length`, `Content-Type`, `ETag`, and `Content-Range`.
|
||||
- Remaining: clean `deleteContentStream` semantics and offset-zero range
|
||||
- Done for `deleteContentStream` tombstone semantics and offset-zero range
|
||||
classification.
|
||||
- `appendContentStream()` remains explicitly unsupported by design.
|
||||
|
||||
## D14.6 - Add natural navigation and query depth
|
||||
|
||||
@@ -347,7 +388,32 @@ Acceptance:
|
||||
Progress:
|
||||
|
||||
- Started by isolating OpenCMIS change-token failures as the main T007 maturity
|
||||
gap. Relationship and ACL discovery were not expanded in this pass.
|
||||
gap.
|
||||
- Done for CMIS property/content mutation change-token conflict handling.
|
||||
- Relationship and ACL discovery were not expanded in this pass.
|
||||
|
||||
## D14.9 - Resolve OpenCMIS object/content blocker set
|
||||
|
||||
```task
|
||||
id: KONT-WP-0014-T009
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3c075537-e05f-4240-acab-18c1d60a8efe"
|
||||
```
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Resolve or explicitly classify the remaining OpenCMIS object/content
|
||||
blockers: CMIS exception mapping, `bulkUpdateProperties`,
|
||||
`deleteContentStream`, change-token conflict handling,
|
||||
`createDocumentFromSource`/copy, and offset-zero range classification.
|
||||
- Rerun the OpenCMIS `repository-type` and `object-content` baseline.
|
||||
- Persist the timestamped results and update the CMIS scorecard.
|
||||
|
||||
Progress:
|
||||
|
||||
- Done. The selected baseline now completes with warnings only and no
|
||||
unexpected findings.
|
||||
|
||||
## D14.8 - Expand OpenCMIS assessment and update maturity scorecard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user