Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:

integration-test:
runs-on: ubuntu-latest
timeout-minutes: 90
timeout-minutes: 240
needs: deploy
environment: dev
env:
Expand Down
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions app/single-tenant/central-space/incidents-app/srv/service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "application",
"i18n": "i18n/i18n.properties",
"applicationVersion": {
"version": "${applicationVersion}"
"version": "0.0.2"
},
"title": "{{appTitle}}",
"description": "{{appDescription}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 28 additions & 7 deletions test/integration/attachments-sdm-multifacet.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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");
}
}

});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading