Skip to content

Commit 2c2b9fc

Browse files
authored
Merge pull request #293 from cap-js/SDMEXT-3019-feature
Readme Update, leading app change and Automation test for client auth
2 parents 7e198ae + 5cea003 commit 2c2b9fc

8 files changed

Lines changed: 125 additions & 10 deletions

File tree

.github/workflows/multiTenant_deploy_and_Integration_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
7171
integration-test:
7272
runs-on: ubuntu-latest
73-
timeout-minutes: 90
73+
timeout-minutes: 240
7474
needs: deploy
7575
environment: dev
7676
env:

README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei
3030
- [Support for Edit of Link type attachments](#support-for-edit-of-link-type-attachments)
3131
- [Support for Non-Draft Attachments](#support-for-non-draft-attachments)
3232
- [Support for Multiple attachment facets](#support-for-multiple-attachment-facets)
33+
- [Support for Large File Upload](#support-for-large-file-upload)
3334
- [Support for Technical User](#support-for-technical-user)
35+
- [Force Client Credentials Flow via Annotation](#force-client-credentials-flow-via-annotation)
3436
- [Support for Multitenancy](#support-for-multitenancy)
3537
- [Deploying and testing the application](#deploying-and-testing-the-application)
3638
- [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:
756758
}
757759
```
758760

761+
## Support for Large File Upload
762+
763+
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.
764+
765+
### Key Features
766+
767+
- **Automatic Routing**: Files ≤ 400 MB use the existing single-POST path; files > 400 MB use a chunked upload path
768+
- **Streaming Upload**: Files > 400 MB are streamed in 20 MB chunks via CMIS `appendContentStream`, avoiding out-of-memory errors
769+
- **Read-Ahead Buffering**: Up to 4 chunks (80 MB max) are pre-loaded while the previous chunk is uploading, improving throughput
770+
- **Failure Recovery**: In-progress upload IDs are tracked in an orphan queue; incomplete documents are deleted with exponential-backoff retries on failure
771+
- **Client Disconnect Handling**: Partial uploads are cleanly cleaned up if the OData client drops the connection mid-upload
772+
- **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
773+
774+
### How It Works
775+
776+
For attachment uploads via OData `PUT .../content`, the plugin automatically:
777+
778+
1. **Detects file size** from the HTTP `Content-Length` header before any data is streamed
779+
2. **Routes small files (≤ 400 MB)** through the existing single-POST `createDocument` path — no change in behavior
780+
3. **Routes large files (> 400 MB)** through the chunked path:
781+
- Creates an empty placeholder document in SDM via `createDocument`
782+
- Streams the file in 20 MB chunks via `appendContentStream`, with the last chunk marked `isLastChunk=true`
783+
- Pre-loads up to 4 chunks in a read-ahead buffer while the previous chunk uploads
784+
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
785+
5. **Reconciles on restart**: any orphan queue entry that survived a previous failure is cleaned up by the startup reconciliation job
786+
787+
### Configuration
788+
789+
No client-side or CDS-side configuration is required. The thresholds are constants in the plugin:
790+
791+
| Constant | Value | Purpose |
792+
|---|---|---|
793+
| `FILE_SIZE_THRESHOLD` | 400 MB | Boundary between single-POST and chunked upload paths |
794+
| `CHUNK_SIZE` | 20 MB | Size of each `appendContentStream` chunk |
795+
796+
### Virus Scan Repositories
797+
798+
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.
799+
800+
759801
## Support for Technical User
760802
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.
761803

@@ -766,6 +808,52 @@ entity Incidents as projection on my.Incidents;
766808
}
767809
```
768810

811+
## Force Client Credentials Flow via Annotation
812+
813+
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.
814+
815+
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.
816+
817+
### Key Features
818+
819+
- **Per-Composition Scope**: A parent entity can mix flows — one attachment composition using client-credentials, another using the default JWT-bearer flow
820+
- **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
821+
- **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
822+
- **Default Preserved**: Without the annotation, existing behavior is unchanged — JWT-bearer when a user context is present, client-credentials only as a fallback
823+
824+
### How It Works
825+
826+
For an attachments composition annotated with `@SDM.useClientCredential: true`, the plugin:
827+
828+
1. **Detects the annotation** on the composition target via `req.target` for direct attachment operations, and via composition walking on parent SAVE events
829+
2. **Authenticates every SDM call** with the SDM service binding's `clientid` / `clientsecret` (resolved from `VCAP_SERVICES`)
830+
3. **Stamps `createdBy` / `modifiedBy`** with the same `clientid` on freshly activated draft rows so the plugin DB and the SDM backend show identical principals
831+
832+
### Entity Definition
833+
834+
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:
835+
836+
```cds
837+
using { sap.attachments.Attachments } from '@cap-js/sdm';
838+
839+
service ProcessorService {
840+
entity Incidents as projection on my.Incidents;
841+
}
842+
843+
// References — created by the human end-user (default flow)
844+
extend my.Incidents with {
845+
references : Composition of many Attachments;
846+
footnotes : Composition of many Attachments;
847+
}
848+
849+
// Footnotes — always stored under the SDM technical user
850+
annotate my.Incidents.footnotes with @SDM.useClientCredential: true;
851+
```
852+
853+
### Configuration
854+
855+
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.
856+
769857
## Support for Multitenancy
770858

771859
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.

app/multi-tenant/central-space/cap-js-incidents-app/srv/services.cds

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ extend my.Incidents with {
6060
footnotes : Composition of many Attachments;
6161
}
6262

63+
annotate my.Incidents.footnotes with @SDM.useClientCredential: true;
64+
6365
extend my.Projects with {
6466
references : Composition of many Attachments;
6567
}

app/multi-tenant/personal-space/cap-js-incidents-app/srv/services.cds

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ extend my.Incidents with {
6060
footnotes : Composition of many Attachments;
6161
}
6262

63+
annotate my.Incidents.footnotes with @SDM.useClientCredential: true;
64+
6365
extend my.Projects with {
6466
references : Composition of many Attachments;
6567
}

app/single-tenant/central-space/incidents-app/srv/service.cds

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ extend my.Incidents with {
6161
}
6262
extend my.Projects with { references: Composition of many Attachments }
6363

64+
annotate my.Incidents.footnotes with @SDM.useClientCredential: true;
65+
6466
extend Attachments with {
6567
customProperty1 : Association to WDIRSCodeList
6668
@SDM.Attachments.AdditionalProperty: {

app/single-tenant/personal-space/incidents-app/app/incidents/webapp/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "application",
66
"i18n": "i18n/i18n.properties",
77
"applicationVersion": {
8-
"version": "${applicationVersion}"
8+
"version": "0.0.2"
99
},
1010
"title": "{{appTitle}}",
1111
"description": "{{appDescription}}",

app/single-tenant/personal-space/incidents-app/srv/service.cds

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ extend my.Incidents with {
6161
}
6262
extend my.Projects with { references: Composition of many Attachments }
6363

64-
annotate my.Incidents.references with @SDM.useClientCredential: false;
64+
annotate my.Incidents.footnotes with @SDM.useClientCredential: true;
6565

6666
extend Attachments with {
6767
customProperty1 : Association to WDIRSCodeList

test/integration/attachments-sdm-multifacet.test.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const multifacets = ['attachments', 'references', 'footnotes']
22
const facetStates = new Map()
33
let baselineState
4+
const isClientCredentialFacet = () => process.env.SDM_TEST_FACET === 'footnotes'
45

56
const snapshotFacetState = () => ({
67
token,
@@ -283,7 +284,11 @@ describe('Attachments Integration Tests --CREATE', () => {
283284
throw new Error("Error : " + response.message)
284285
}
285286
response = await apiNoSDMRole.createAttachment(appUrl, serviceName, entityName, incidentID, postData, file);
286-
expect(response.message).toBe("Create attachment API call (put) failed : Request failed with status code 403");
287+
if (isClientCredentialFacet()) {
288+
expect(response.status).toBe("OK");
289+
} else {
290+
expect(response.message).toBe("Create attachment API call (put) failed : Request failed with status code 403");
291+
}
287292
response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, incidentID);
288293
if (response.status !== "OK") {
289294
throw new Error("Error : " + response.message)
@@ -487,7 +492,11 @@ describe('Attachments Integration Tests --READ', () => {
487492
apiNoSDMRole = new Api(config);
488493
const response = await apiNoSDMRole.readAttachment(appUrl, serviceName, entityName, incidentID, attachments[0]);
489494
console.log(response.message);
490-
expect(response.message).toBe("Read attachment API call failed : Request failed with status code 403");
495+
if (isClientCredentialFacet()) {
496+
expect(response.status).toBe("OK");
497+
} else {
498+
expect(response.message).toBe("Read attachment API call failed : Request failed with status code 403");
499+
}
491500
}
492501

493502
});
@@ -1020,7 +1029,11 @@ const config = {
10201029
if (tokenFlow !== 'technicalUser') {
10211030
apiNoSDMRole = new Api(config);
10221031
response = await apiNoSDMRole.openAttachmentSaved(appUrl, serviceName, entityName, linkIncidentID, srvpath, secondLinkAttachmentID);
1023-
expect(response.message).toBe("Open attachment saved API call failed : Request failed with status code 403");
1032+
if (isClientCredentialFacet()) {
1033+
expect(response.status).toBe("OK");
1034+
} else {
1035+
expect(response.message).toBe("Open attachment saved API call failed : Request failed with status code 403");
1036+
}
10241037
}
10251038
// Verify metadata for both links after multiple edits
10261039
response = await api.fetchMetadata(appUrl, serviceName, entityName, linkIncidentID, linkAttachmentID);
@@ -1840,8 +1853,12 @@ const config = {
18401853
// Try to edit the link with valid URL using no-SDM-role user
18411854
const updatedUrl = 'https://updated-norole.com';
18421855
response = await apiNoSDMRole.editLink(appUrl, serviceName, entityName, editLinkIncidentID, editLinkAttachmentID, srvpath, updatedUrl);
1843-
expect(response.status).toBe("FAILED");
1844-
expect(response.message).toBe(userNotAuthorisedErrorEditLink);
1856+
if (isClientCredentialFacet()) {
1857+
expect(response.status).toBe("OK");
1858+
} else {
1859+
expect(response.status).toBe("FAILED");
1860+
expect(response.message).toBe(userNotAuthorisedErrorEditLink);
1861+
}
18451862

18461863
// Save entity draft with no-SDM-role user to exit draft mode
18471864
response = await apiNoSDMRole.saveEntityDraft(appUrl, serviceName, entityName, srvpath, editLinkIncidentID);
@@ -1892,7 +1909,9 @@ const config = {
18921909
expect(response.data.createdBy).toBeTruthy();
18931910
expect(response.data.modifiedBy).toBeTruthy();
18941911

1895-
if (tokenFlow === 'namedUser' && credentials.username) {
1912+
if (isClientCredentialFacet() && credentials.username) {
1913+
expect(response.data.createdBy).not.toBe(credentials.username);
1914+
} else if (tokenFlow === 'namedUser' && credentials.username) {
18961915
expect(response.data.createdBy).toBe(credentials.username);
18971916
} else if (tokenFlow === 'technicalUser' && credentials.username) {
18981917
expect(response.data.createdBy).not.toBe(credentials.username);
@@ -2104,7 +2123,9 @@ describe('Attachments Integration Tests --CMIS METADATA', () => {
21042123
const createdBy = await getCmisProperty(metadataEntityID, "metadata-test.pdf", "cmis:createdBy");
21052124
expect(createdBy).toBeTruthy();
21062125

2107-
if (tokenFlow === 'namedUser') {
2126+
if (isClientCredentialFacet() && credentials.username) {
2127+
expect(createdBy).not.toBe(credentials.username);
2128+
} else if (tokenFlow === 'namedUser') {
21082129
expect(createdBy).toBe(credentials.username);
21092130
} else if (tokenFlow === 'technicalUser') {
21102131
expect(createdBy).not.toBe(credentials.username);

0 commit comments

Comments
 (0)