diff --git a/.github/workflows/multiTenant_deploy_and_Integration_test.yml b/.github/workflows/multiTenant_deploy_and_Integration_test.yml index d2a95904..c12f1219 100644 --- a/.github/workflows/multiTenant_deploy_and_Integration_test.yml +++ b/.github/workflows/multiTenant_deploy_and_Integration_test.yml @@ -70,7 +70,7 @@ jobs: integration-test: runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 240 needs: deploy environment: dev env: diff --git a/README.md b/README.md index 54d93f90..4669edf7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - [Support for Edit of Link type attachments](#support-for-edit-of-link-type-attachments) - [Support for Non-Draft Attachments](#support-for-non-draft-attachments) - [Support for Multiple attachment facets](#support-for-multiple-attachment-facets) +- [Support for Large File Upload](#support-for-large-file-upload) - [Support for Technical User](#support-for-technical-user) +- [Force Client Credentials Flow via Annotation](#force-client-credentials-flow-via-annotation) - [Support for Multitenancy](#support-for-multitenancy) - [Deploying and testing the application](#deploying-and-testing-the-application) - [Running the unit tests](#running-the-unit-tests) @@ -756,6 +758,46 @@ For row-press behavior in every facet table, configure each line item target: } ``` +## Support for Large File Upload + +This plugin supports uploading files larger than 400 MB to SAP Document Management (SDM) without buffering the entire file in memory. The plugin automatically detects file size and routes the upload through either the single-POST path or a chunked path. Clients use the same OData `PUT .../content` request regardless of file size. + +### Key Features + +- **Automatic Routing**: Files ≤ 400 MB use the existing single-POST path; files > 400 MB use a chunked upload path +- **Streaming Upload**: Files > 400 MB are streamed in 20 MB chunks via CMIS `appendContentStream`, avoiding out-of-memory errors +- **Read-Ahead Buffering**: Up to 4 chunks (80 MB max) are pre-loaded while the previous chunk is uploading, improving throughput +- **Failure Recovery**: In-progress upload IDs are tracked in an orphan queue; incomplete documents are deleted with exponential-backoff retries on failure +- **Client Disconnect Handling**: Partial uploads are cleanly cleaned up if the OData client drops the connection mid-upload +- **Virus Scan Guard**: For repositories with virus scanning enabled, files > 400 MB are rejected upfront with HTTP 409 since SDM's virus scan service does not support files above this size + +### How It Works + +For attachment uploads via OData `PUT .../content`, the plugin automatically: + +1. **Detects file size** from the HTTP `Content-Length` header before any data is streamed +2. **Routes small files (≤ 400 MB)** through the existing single-POST `createDocument` path — no change in behavior +3. **Routes large files (> 400 MB)** through the chunked path: + - Creates an empty placeholder document in SDM via `createDocument` + - Streams the file in 20 MB chunks via `appendContentStream`, with the last chunk marked `isLastChunk=true` + - Pre-loads up to 4 chunks in a read-ahead buffer while the previous chunk uploads +4. **Tracks orphans on failure**: if any chunk upload fails, the placeholder objectId is added to an orphan queue and the plugin attempts to delete the incomplete document with retry backoff +5. **Reconciles on restart**: any orphan queue entry that survived a previous failure is cleaned up by the startup reconciliation job + +### Configuration + +No client-side or CDS-side configuration is required. The thresholds are constants in the plugin: + +| Constant | Value | Purpose | +|---|---|---| +| `FILE_SIZE_THRESHOLD` | 400 MB | Boundary between single-POST and chunked upload paths | +| `CHUNK_SIZE` | 20 MB | Size of each `appendContentStream` chunk | + +### Virus Scan Repositories + +SAP Document Management's virus-scan service does not support files above 400 MB. When `isVirusScanEnabled: true` is set on the SDM service binding, the plugin rejects uploads larger than 400 MB with HTTP 409 and a descriptive error message before any data is streamed, instead of letting the request fail later at the SDM side. Repositories without virus scanning are unaffected. + + ## Support for Technical User The CAP OData operations can be performed on attachments using a technical user. This flow can be used for machine-to-machine (M2M) interactions, where user involvement is not necessary. @@ -766,6 +808,52 @@ entity Incidents as projection on my.Incidents; } ``` +## Force Client Credentials Flow via Annotation + +By default, the plugin uses the JWT-bearer flow when a user context is present in the incoming token (named-user authentication), and falls back to client-credentials only for technical users that have no user origin. Some scenarios — for example, customer requirements where end users do not have SDM roles but the application still needs to upload, rename, edit links, and update attachment metadata on their behalf — need the client-credentials flow regardless of whether the token carries a user context. + +The `@SDM.useClientCredential: true` annotation on an attachments composition opts that composition into the client-credentials flow for all CRUD operations, irrespective of the calling user. + +### Key Features + +- **Per-Composition Scope**: A parent entity can mix flows — one attachment composition using client-credentials, another using the default JWT-bearer flow +- **Flow Override on All CRUD Paths**: Create, upload, rename, edit links, update metadata, and delete are all routed through the technical user when the annotation is set +- **Aligned `createdBy` / `modifiedBy`**: The plugin DB columns are stamped with the SDM client_id so the UI matches `cmis:createdBy` / `cmis:modifiedBy` recorded by DMS / DI +- **Default Preserved**: Without the annotation, existing behavior is unchanged — JWT-bearer when a user context is present, client-credentials only as a fallback + +### How It Works + +For an attachments composition annotated with `@SDM.useClientCredential: true`, the plugin: + +1. **Detects the annotation** on the composition target via `req.target` for direct attachment operations, and via composition walking on parent SAVE events +2. **Authenticates every SDM call** with the SDM service binding's `clientid` / `clientsecret` (resolved from `VCAP_SERVICES`) +3. **Stamps `createdBy` / `modifiedBy`** with the same `clientid` on freshly activated draft rows so the plugin DB and the SDM backend show identical principals + +### Entity Definition + +The annotation must live on the attachments **target** (the composition target entity). In the sample Incidents app, the `footnotes` composition is annotated so footnote attachments are always uploaded under the technical user, while the human-user-authored `references` composition keeps the default flow: + +```cds +using { sap.attachments.Attachments } from '@cap-js/sdm'; + +service ProcessorService { + entity Incidents as projection on my.Incidents; +} + +// References — created by the human end-user (default flow) +extend my.Incidents with { + references : Composition of many Attachments; + footnotes : Composition of many Attachments; +} + +// Footnotes — always stored under the SDM technical user +annotate my.Incidents.footnotes with @SDM.useClientCredential: true; +``` + +### Configuration + +The SDM service binding must be available in `VCAP_SERVICES` so the plugin can resolve the client credentials. This is the normal binding setup; no extra configuration is required. + ## Support for Multitenancy This plugin automates repository lifecycle management in a multi-tenant setup. On tenant subscription, it provisions a repository and stores its details, and on unsubscription, it securely cleans up the repository. diff --git a/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds b/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds index bf0cdd33..0a3de3f7 100644 --- a/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds +++ b/app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds @@ -60,6 +60,8 @@ extend my.Incidents with { footnotes : Composition of many Attachments; } +annotate my.Incidents.footnotes with @SDM.useClientCredential: true; + extend my.Projects with { references : Composition of many Attachments; } diff --git a/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds b/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds index bf0cdd33..0a3de3f7 100644 --- a/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds +++ b/app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds @@ -60,6 +60,8 @@ extend my.Incidents with { footnotes : Composition of many Attachments; } +annotate my.Incidents.footnotes with @SDM.useClientCredential: true; + extend my.Projects with { references : Composition of many Attachments; } diff --git a/app/single-tenant/central-space/incidents-app/srv/service.cds b/app/single-tenant/central-space/incidents-app/srv/service.cds index 8bf831d3..9f98bd78 100644 --- a/app/single-tenant/central-space/incidents-app/srv/service.cds +++ b/app/single-tenant/central-space/incidents-app/srv/service.cds @@ -61,6 +61,8 @@ extend my.Incidents with { } extend my.Projects with { references: Composition of many Attachments } +annotate my.Incidents.footnotes with @SDM.useClientCredential: true; + extend Attachments with { customProperty1 : Association to WDIRSCodeList @SDM.Attachments.AdditionalProperty: { diff --git a/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json b/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json index 36176963..d2a7b458 100644 --- a/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json +++ b/app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json @@ -5,7 +5,7 @@ "type": "application", "i18n": "i18n/i18n.properties", "applicationVersion": { - "version": "${applicationVersion}" + "version": "0.0.2" }, "title": "{{appTitle}}", "description": "{{appDescription}}", diff --git a/app/single-tenant/personal-space/incidents-app/srv/service.cds b/app/single-tenant/personal-space/incidents-app/srv/service.cds index 63cc2b0b..9f98bd78 100644 --- a/app/single-tenant/personal-space/incidents-app/srv/service.cds +++ b/app/single-tenant/personal-space/incidents-app/srv/service.cds @@ -61,7 +61,7 @@ extend my.Incidents with { } extend my.Projects with { references: Composition of many Attachments } -annotate my.Incidents.references with @SDM.useClientCredential: false; +annotate my.Incidents.footnotes with @SDM.useClientCredential: true; extend Attachments with { customProperty1 : Association to WDIRSCodeList diff --git a/test/integration/attachments-sdm-multifacet.test.js b/test/integration/attachments-sdm-multifacet.test.js index 0aa79ce8..e9268d31 100644 --- a/test/integration/attachments-sdm-multifacet.test.js +++ b/test/integration/attachments-sdm-multifacet.test.js @@ -1,6 +1,7 @@ const multifacets = ['attachments', 'references', 'footnotes'] const facetStates = new Map() let baselineState +const isClientCredentialFacet = () => process.env.SDM_TEST_FACET === 'footnotes' const snapshotFacetState = () => ({ token, @@ -283,7 +284,11 @@ describe('Attachments Integration Tests --CREATE', () => { throw new Error("Error : " + response.message) } response = await apiNoSDMRole.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file); - expect(response.message).toBe("Create attachment API call (put) failed : Request failed with status code 403"); + if (isClientCredentialFacet()) { + expect(response.status).toBe("OK"); + } else { + expect(response.message).toBe("Create attachment API call (put) failed : Request failed with status code 403"); + } response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID); if (response.status !== "OK") { throw new Error("Error : " + response.message) @@ -487,7 +492,11 @@ describe('Attachments Integration Tests --READ', () => { apiNoSDMRole = new Api(config); const response = await apiNoSDMRole.readAttachment(appUrl, serviceName, entityName, incidentID, attachments[0]); console.log(response.message); - expect(response.message).toBe("Read attachment API call failed : Request failed with status code 403"); + if (isClientCredentialFacet()) { + expect(response.status).toBe("OK"); + } else { + expect(response.message).toBe("Read attachment API call failed : Request failed with status code 403"); + } } }); @@ -1020,7 +1029,11 @@ const config = { if (tokenFlow !== 'technicalUser') { apiNoSDMRole = new Api(config); response = await apiNoSDMRole.openAttachmentSaved(appUrl, serviceName, entityName, linkIncidentID, srvpath, secondLinkAttachmentID); - expect(response.message).toBe("Open attachment saved API call failed : Request failed with status code 403"); + if (isClientCredentialFacet()) { + expect(response.status).toBe("OK"); + } else { + expect(response.message).toBe("Open attachment saved API call failed : Request failed with status code 403"); + } } // Verify metadata for both links after multiple edits response = await api.fetchMetadata(appUrl, serviceName, entityName, linkIncidentID, linkAttachmentID); @@ -1840,8 +1853,12 @@ const config = { // Try to edit the link with valid URL using no-SDM-role user const updatedUrl = 'https://updated-norole.com'; response = await apiNoSDMRole.editLink(appUrl, serviceName, entityName, editLinkIncidentID, editLinkAttachmentID, srvpath, updatedUrl); - expect(response.status).toBe("FAILED"); - expect(response.message).toBe(userNotAuthorisedErrorEditLink); + if (isClientCredentialFacet()) { + expect(response.status).toBe("OK"); + } else { + expect(response.status).toBe("FAILED"); + expect(response.message).toBe(userNotAuthorisedErrorEditLink); + } // Save entity draft with no-SDM-role user to exit draft mode response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, editLinkIncidentID); @@ -1892,7 +1909,9 @@ const config = { expect(response.data.createdBy).toBeTruthy(); expect(response.data.modifiedBy).toBeTruthy(); - if (tokenFlow === 'namedUser' && credentials.username) { + if (isClientCredentialFacet() && credentials.username) { + expect(response.data.createdBy).not.toBe(credentials.username); + } else if (tokenFlow === 'namedUser' && credentials.username) { expect(response.data.createdBy).toBe(credentials.username); } else if (tokenFlow === 'technicalUser' && credentials.username) { expect(response.data.createdBy).not.toBe(credentials.username); @@ -2104,7 +2123,9 @@ describe('Attachments Integration Tests --CMIS METADATA', () => { const createdBy = await getCmisProperty(metadataEntityID, "metadata-test.pdf", "cmis:createdBy"); expect(createdBy).toBeTruthy(); - if (tokenFlow === 'namedUser') { + if (isClientCredentialFacet() && credentials.username) { + expect(createdBy).not.toBe(credentials.username); + } else if (tokenFlow === 'namedUser') { expect(createdBy).toBe(credentials.username); } else if (tokenFlow === 'technicalUser') { expect(createdBy).not.toBe(credentials.username);