diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc817d4..32076bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - If `@cap-js/audit-logging` is installed automatically trigger audit logs for the security events. - Duplicate file names to a single attachment entity are automatically assigned a distinguishing suffix. - Local testing using a Postgres database now possible. +- Native server-side `copy()` method on `AttachmentsService` for copying attachments between entities without transferring binary data through the application. Supports all storage backends (DB, AWS S3, Azure Blob Storage, GCP Cloud Storage) with backend-native copy operations. ### Fixed diff --git a/README.md b/README.md index 8e53095d..1b2f8a89 100755 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The `@cap-js/attachments` package is a [CDS plugin](https://cap.cloud.sap/docs/n - [Visibility Control for Attachments UI Facet Generation](#visibility-control-for-attachments-ui-facet-generation) - [Example Usage](#example-usage) - [Non-Draft Upload](#non-draft-upload) + - [Copying Attachments](#copying-attachments) - [Specify the maximum file size](#specify-the-maximum-file-size) - [Restrict allowed MIME types](#restrict-allowed-mime-types) - [Minimum and Maximum Number of Attachments](#minimum-and-maximum-number-of-attachments) @@ -289,6 +290,79 @@ entity Incidents { } ``` +### Copying Attachments + +The `AttachmentsService` exposes a programmatic `copy()` method that copies an attachment to a new record. On cloud storage backends (AWS S3, Azure Blob Storage, GCP Cloud Storage) this uses a backend-native server-side copy — no binary data is transferred through your application. On database storage it reads and inserts the content directly. + +**Signature:** + +```js +const AttachmentsSrv = await cds.connect.to("attachments") +await AttachmentsSrv.copy( + sourceAttachmentsEntity, + sourceKeys, + targetAttachmentsEntity, + (targetKeys = {}), +) +``` + +| Parameter | Description | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sourceAttachmentsEntity` | CDS entity definition of the source attachment composition. | +| `sourceKeys` | Keys of the attachment (e.g. `{ ID: '...' }`) | +| `targetAttachmentsEntity` | CDS entity definition of the target attachment composition. | +| `targetKeys` | Parent FK fields for the new record (e.g. `{ up__ID: '...' }`). When `targetAttachmentsEntity` is a draft table, must also include `DraftAdministrativeData_DraftUUID`. | + +The scan `status`, `lastScan`, and `hash` are inherited from the source — no re-scan is triggered since the binary content is identical. Copying an attachment when the status is not `Clean` is rejected with a `400` error. + +> [!NOTE] +> Only copies within the same tenant are supported. Cross-tenant copies are not possible. + +#### Examples + +
+ +copy between two active records: + +```js +const { Incidents } = ProcessorService.entities + +await AttachmentsSrv.copy( + Incidents.attachments, + { ID: sourceAttachmentID }, + Incidents.attachments, + { up__ID: targetIncidentID }, +) +``` + +
+ +
+ +copy into a new draft record (e.g. creating an incident from a template) + +```js +const { Incidents } = ProcessorService.entities + +// Look up the draft session UUID for the target incident's open draft +const targetDraft = await SELECT.one + .from(Incidents.drafts, { ID: targetIncidentID }) + .columns("DraftAdministrativeData_DraftUUID") + +await AttachmentsSrv.copy( + Incidents.attachments, + { ID: sourceAttachmentID }, + Incidents.attachments.drafts, + { + up__ID: targetIncidentID, + DraftAdministrativeData_DraftUUID: + targetDraft.DraftAdministrativeData_DraftUUID, + }, +) +``` + +
+ ### Non-Draft Upload For scenarios where the entity is not draft-enabled, for example [`tests/non-draft-request.http`](./tests/non-draft-request.http), separate HTTP requests for metadata creation and asset uploading need to be performed manually. diff --git a/eslint.config.mjs b/eslint.config.mjs index 0833481e..bf8492b8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,14 @@ import cds from "@sap/cds/eslint.config.mjs" export default [ ...cds, + { + rules: { + "no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", reportUsedIgnorePattern: true }, + ], + }, + }, { name: "test-files-config", files: ["tests/**/*"], diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js index 9d312889..299bb333 100644 --- a/lib/generic-handlers.js +++ b/lib/generic-handlers.js @@ -9,9 +9,6 @@ const { } = require("./helper") const { getMime } = require("./mime") -const isMultitenacyEnabled = !!cds.env.requires.multitenancy -const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind - /** * Finalizes the preparation of a single attachment's data * @param {object} data - The attachment data @@ -57,10 +54,9 @@ async function finalizePrepareAttachment(data, req) { } if (!data.url) { - data.url = - isMultitenacyEnabled && objectStoreKind === "shared" - ? `${req.tenant}_${cds.utils.uuid()}` - : cds.utils.uuid() + const attachment = await cds.connect.to("attachments") + // Generate URL for object store + data.url = await attachment.createUrlForAttachment(data) } data.ID ??= cds.utils.uuid() @@ -368,10 +364,9 @@ async function validateAndInsertAttachmentFromDBHandler(data, target, req) { ) return - // Generate URL for object store - data.url = cds.utils.uuid() - const attachment = await cds.connect.to("attachments") + // Generate URL for object store + data.url = await attachment.createUrlForAttachment(data) // Let the attachments service handle storage (object store + DB metadata) await attachment.put(target, data) diff --git a/srv/aws-s3.js b/srv/aws-s3.js index 7afe1592..f0e02a13 100644 --- a/srv/aws-s3.js +++ b/srv/aws-s3.js @@ -3,6 +3,7 @@ const { GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, + CopyObjectCommand, } = require("@aws-sdk/client-s3") const { Upload } = require("@aws-sdk/lib-storage") const cds = require("@sap/cds") @@ -328,6 +329,43 @@ module.exports = class AWSAttachmentsService extends require("./object-store") { } } + /** + * @inheritdoc + */ + async copy( + sourceAttachmentsEntity, + sourceKeys, + targetAttachmentsEntity, + targetKeys = {}, + ) { + LOG.debug("Copying attachment (S3)", { + source: sourceAttachmentsEntity.name, + sourceKeys, + target: targetAttachmentsEntity.name, + }) + const safeTargetKeys = this._sanitizeTargetKeys(targetKeys) + const { source, newID, newUrl } = await this._prepareCopy( + sourceAttachmentsEntity, + sourceKeys, + ) + const { client, bucket } = await this.retrieveClient() + if (await this.exists(newUrl)) { + const err = new Error("Target blob already exists") + err.status = 409 + throw err + } + await client.send( + new CopyObjectCommand({ + Bucket: bucket, + CopySource: `${bucket}/${source.url}`, + Key: newUrl, + }), + ) + const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl } + await INSERT(newRecord).into(targetAttachmentsEntity) + return newRecord + } + /** * Deletes a file from S3 based on the provided key * @param {string} Key - The key of the file to delete diff --git a/srv/azure-blob-storage.js b/srv/azure-blob-storage.js index 040fd26f..c312bc6a 100644 --- a/srv/azure-blob-storage.js +++ b/srv/azure-blob-storage.js @@ -305,6 +305,39 @@ module.exports = class AzureAttachmentsService extends ( } } + /** + * @inheritdoc + */ + async copy( + sourceAttachmentsEntity, + sourceKeys, + targetAttachmentsEntity, + targetKeys = {}, + ) { + LOG.debug("Copying attachment (Azure)", { + source: sourceAttachmentsEntity.name, + sourceKeys, + target: targetAttachmentsEntity.name, + }) + const safeTargetKeys = this._sanitizeTargetKeys(targetKeys) + const { source, newID, newUrl } = await this._prepareCopy( + sourceAttachmentsEntity, + sourceKeys, + ) + const { containerClient } = await this.retrieveClient() + if (await this.exists(newUrl)) { + const err = new Error("Target blob already exists") + err.status = 409 + throw err + } + const sourceBlobClient = containerClient.getBlockBlobClient(source.url) + const targetBlobClient = containerClient.getBlockBlobClient(newUrl) + await targetBlobClient.syncCopyFromURL(sourceBlobClient.url) + const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl } + await INSERT(newRecord).into(targetAttachmentsEntity) + return newRecord + } + /** * Deletes a file from Azure Blob Storage * @param {string} Key - The key of the file to delete diff --git a/srv/basic.js b/srv/basic.js index 0370b7d4..83598c48 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -485,6 +485,132 @@ class AttachmentsService extends cds.Service { } } + /** + * Strips protected fields from targetKeys so callers cannot override + * security-sensitive metadata (status, hash, url, etc.). + * @param {object} targetKeys - Raw target keys from the caller + * @returns {object} - Sanitized target keys containing only FK fields + */ + _sanitizeTargetKeys(targetKeys) { + const sanitized = {} + for (const [key, value] of Object.entries(targetKeys)) { + if (key.startsWith("up_") || key.startsWith("DraftAdministrativeData")) { + sanitized[key] = value + } else { + LOG.warn(`Ignoring protected field in targetKeys: ${key}`) + } + } + return sanitized + } + + /** + * + * @param {*} data + * @returns + */ + createUrlForAttachment() { + const isMultiTenancyEnabled = !!cds.env.requires.multitenancy + const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind + return isMultiTenancyEnabled && objectStoreKind === "shared" + ? `${cds.context.tenant}_${cds.utils.uuid()}` + : cds.utils.uuid() + } + + /** + * Prepares a copy operation by validating the source and generating new identifiers. + * Shared by all storage backends. + * @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition + * @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' }) + * @returns {Promise<{ source: object, newID: string, newUrl: string }>} + */ + async _prepareCopy(sourceAttachmentsEntity, sourceKeys) { + // srv.run so auth is enforced + const srv = await cds.connect.to( + sourceAttachmentsEntity._service?.name ?? "db", + ) + const source = await srv.run( + SELECT.one + .from(sourceAttachmentsEntity, sourceKeys) + .columns( + "url", + "filename", + "mimeType", + "note", + "hash", + "status", + "lastScan", + ), + ) + if (!source) { + const err = new Error("Source attachment not found") + err.status = 404 + throw err + } + if (source.status !== "Clean") { + const err = new Error( + `Cannot copy attachment with status: ${source.status}. Only a Clean Status is allowed`, + ) + err.status = 400 + throw err + } + const newUrl = this.createUrlForAttachment(source) + + return { source, newID: cds.utils.uuid(), newUrl } + } + + /** + * Copies an attachment to a new record, reusing the binary content from storage. + * For DB storage, reads content and inserts a new record. + * Cloud backends override this to use native server-side copy. + * Scan status, lastScan, and hash are inherited from the source — no re-scan needed. + * + * Tenant note: only copies within the same tenant are supported. Cross-tenant + * copies are not allowed because the storage backends resolve credentials for + * the current tenant only. + * + * @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition. + * Pass `sourceAttachmentsEntity.drafts` to copy from a draft-only source. + * @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' }) + * @param {import('@sap/cds').Entity} targetAttachmentsEntity - Target attachment entity definition. + * Pass `targetAttachmentsEntity.drafts` to insert into the draft shadow table (i.e. when the target + * parent entity is currently in a draft editing session). In that case targetKeys must include + * DraftAdministrativeData_DraftUUID. + * @param {object} [targetKeys={}] - Parent FK fields for the new record (e.g. { up__ID: '...' }). + * When targeting a draft table, must also include DraftAdministrativeData_DraftUUID. + * Protected fields (status, hash, url, etc.) are stripped automatically. + * @returns {Promise} - New attachment metadata (without content) + */ + async copy( + sourceAttachmentsEntity, + sourceKeys, + targetAttachmentsEntity, + targetKeys = {}, + ) { + LOG.debug("Copying attachment (DB)", { + source: sourceAttachmentsEntity.name, + sourceKeys, + target: targetAttachmentsEntity.name, + }) + const safeTargetKeys = this._sanitizeTargetKeys(targetKeys) + const { source, newID, newUrl } = await this._prepareCopy( + sourceAttachmentsEntity, + sourceKeys, + ) + const content = await this.get(sourceAttachmentsEntity, sourceKeys) + const newRecord = { + ...source, + // Must be spread into afterwards else source up_ overrides target keys + ...safeTargetKeys, + ID: newID, + url: newUrl, + } + await INSERT.into(targetAttachmentsEntity).entries({ + ...newRecord, + content, + }) + return newRecord + } + /** * Deletes a file from the database. Does not delete metadata * @param {string} url - The url of the file to delete diff --git a/srv/gcp.js b/srv/gcp.js index 45477782..fa92954d 100644 --- a/srv/gcp.js +++ b/srv/gcp.js @@ -104,6 +104,17 @@ module.exports = class GoogleAttachmentsService extends ( } } + /** + * Checks if a file exists in Google Cloud Storage + * @param {string} fileName - The name/key of the file to check + * @returns {Promise} - True if the file exists, false otherwise + */ + async exists(fileName) { + const { bucket } = await this.retrieveClient() + const [exists] = await bucket.file(fileName).exists() + return exists + } + /** * @inheritdoc */ @@ -151,8 +162,7 @@ module.exports = class GoogleAttachmentsService extends ( const file = bucket.file(blobName) - const [exists] = await file.exists() - if (exists) { + if (await this.exists(blobName)) { const error = new Error("Attachment already exists") error.status = 409 throw error @@ -336,6 +346,37 @@ module.exports = class GoogleAttachmentsService extends ( } } + /** + * @inheritdoc + */ + async copy( + sourceAttachmentsEntity, + sourceKeys, + targetAttachmentsEntity, + targetKeys = {}, + ) { + LOG.debug("Copying attachment (GCP)", { + source: sourceAttachmentsEntity.name, + sourceKeys, + target: targetAttachmentsEntity.name, + }) + const safeTargetKeys = this._sanitizeTargetKeys(targetKeys) + const { source, newID, newUrl } = await this._prepareCopy( + sourceAttachmentsEntity, + sourceKeys, + ) + const { bucket } = await this.retrieveClient() + if (await this.exists(newUrl)) { + const err = new Error("Target blob already exists") + err.status = 409 + throw err + } + await bucket.file(source.url).copy(bucket.file(newUrl)) + const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl } + await INSERT(newRecord).into(targetAttachmentsEntity) + return newRecord + } + /** * Deletes a file from Google Cloud Platform * @param {string} Key - The key of the file to delete diff --git a/srv/malwareScanner.js b/srv/malwareScanner.js index 396f43e5..2c948d6a 100644 --- a/srv/malwareScanner.js +++ b/srv/malwareScanner.js @@ -99,31 +99,29 @@ class MalwareScanner extends cds.ApplicationService { await this.updateStatus(_target, Object.assign({ hash }, keys), status) } - async getFileInformation(_target, keys) { + async getFileInformation(target, keys) { const dbResult = await SELECT.one - .from(_target.drafts || _target) + .from(target.drafts || target) .columns("mimeType") .where(keys) return dbResult } - async updateStatus(_target, keys, status) { - if (_target.drafts) { + async updateStatus(target, keys, status) { + if (target.drafts) { await Promise.all([ - UPDATE.entity(_target) - .where(keys) - .set({ status, lastScan: new Date() }), - UPDATE.entity(_target.drafts) + UPDATE.entity(target).where(keys).set({ status, lastScan: new Date() }), + UPDATE.entity(target.drafts) .where(keys) .set({ status, lastScan: new Date() }), ]) } else { - await UPDATE.entity(_target) + await UPDATE.entity(target) .where(keys) .set({ status, lastScan: new Date() }) } LOG.info( - `Updated scan status to ${status} for ${_target.name}, ${JSON.stringify(keys)}`, + `Updated scan status to ${status} for ${target.name}, ${JSON.stringify(keys)}`, ) } diff --git a/tests/incidents-app/app/incidents/annotations.cds b/tests/incidents-app/app/incidents/annotations.cds index 4c856500..8a3dfcfa 100644 --- a/tests/incidents-app/app/incidents/annotations.cds +++ b/tests/incidents-app/app/incidents/annotations.cds @@ -154,6 +154,17 @@ annotate service.Incidents with { ![@UI.TextArrangement] : #TextOnly, } }; + +annotate service.Incidents with @( + UI.Identification : [{ + $Type : 'UI.DataFieldForAction', + Label : 'Copy Incident', + Action : 'ProcessorService.copyIncident', + ![@UI.Hidden] : { $edmJson: { $Not: { $Path: 'IsActiveEntity' } } }, + InvocationGrouping : #Isolated, + }] +); + annotate service.Incidents.conversation with @( title : '{i18n>Conversation}', UI.LineItem #i18nConversation1 : [ diff --git a/tests/incidents-app/srv/services.cds b/tests/incidents-app/srv/services.cds index 1643c8d9..7072ec74 100644 --- a/tests/incidents-app/srv/services.cds +++ b/tests/incidents-app/srv/services.cds @@ -6,7 +6,9 @@ using from '../db/attachments'; */ service ProcessorService { @cds.redirection.target - entity Incidents as projection on my.Incidents; + entity Incidents as projection on my.Incidents actions { + action copyIncident() returns Incidents; + }; entity Customers @readonly as projection on my.Customers; diff --git a/tests/incidents-app/srv/services.js b/tests/incidents-app/srv/services.js index caf54db2..780a6408 100644 --- a/tests/incidents-app/srv/services.js +++ b/tests/incidents-app/srv/services.js @@ -24,10 +24,59 @@ class ProcessorService extends cds.ApplicationService { ) this.on("insertTestData", () => this.insertTestData()) + this.on("copyIncident", (req) => this.onCopyIncident(req)) return res } + async onCopyIncident(req) { + const { Incidents } = this.entities + const Attachments = this.entities["Incidents.attachments"] + const sourceID = req.params[0]?.ID ?? req.params[0] + + // Read source incident fields + const source = await SELECT.one + .from(Incidents, { ID: sourceID }) + .columns("title", "customer_ID", "urgency_code") + if (!source) return req.reject(404, "Source incident not found") + + // Create a new draft incident + const newDraft = await this.new(Incidents.drafts, { + title: source.title + " (Copy)", + customer_ID: source.customer_ID, + urgency_code: source.urgency_code, + }) + + // Look up the DraftUUID for the new draft + const draftAdmin = await SELECT.one + .from(Incidents.drafts, { ID: newDraft.ID }) + .columns("DraftAdministrativeData_DraftUUID") + if (!draftAdmin?.DraftAdministrativeData_DraftUUID) + return req.reject(500, "Failed to create draft") + + // Copy all attachments from the active source into the new draft + const sourceAttachmentsEntity = await SELECT.from(Attachments).where({ + up__ID: sourceID, + }) + if (sourceAttachmentsEntity.length > 0) { + const AttachmentsSrv = await cds.connect.to("attachments") + for (const att of sourceAttachmentsEntity) { + await AttachmentsSrv.copy( + Attachments, + { ID: att.ID }, + Attachments.drafts, + { + up__ID: newDraft.ID, + DraftAdministrativeData_DraftUUID: + draftAdmin.DraftAdministrativeData_DraftUUID, + }, + ) + } + } + + return newDraft + } + async insertTestData() { const firstID = cds.utils.uuid() const secondID = cds.utils.uuid() diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 42a9df7c..24d3cb18 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -6,6 +6,7 @@ const { delay, waitForMalwareDeletion, waitForDeletion, + runWithUser, } = require("../utils/testUtils") const { createReadStream, readFileSync } = cds.utils.fs const { join, basename } = cds.utils.path @@ -14,6 +15,7 @@ const { Readable } = require("stream") const app = join(__dirname, "../incidents-app") const { axios, GET, POST, DELETE, PATCH, PUT } = cds.test(app) axios.defaults.auth = { username: "alice" } +const alice = new cds.User({ id: "alice", roles: { admin: 1, support: 1 } }) let utils = null @@ -3266,6 +3268,287 @@ describe("Testing to prevent crash due to recursive overflow", () => { }) }) +describe("Tests for copy() on AttachmentsService", () => { + beforeAll(async () => { + utils = new RequestSend(POST) + }) + + it("Copies a clean attachment to a different incident", async () => { + const sourceIncidentID = await newIncident(POST, "processor") + const targetIncidentID = await newIncident(POST, "processor") + const sourceCleanWaiter = waitForScanStatus("Clean") + + const sourceAttachmentID = await uploadDraftAttachment( + utils, + POST, + GET, + sourceIncidentID, + ) + expect(sourceAttachmentID).toBeTruthy() + await utils.draftModeSave( + "processor", + "Incidents", + targetIncidentID, + "ProcessorService", + ) + await sourceCleanWaiter + + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + const newAtt = await runWithUser(alice, () => + AttachmentsSrv.copy( + Attachments, + { ID: sourceAttachmentID }, + Attachments, + { up__ID: targetIncidentID }, + ), + ) + expect(newAtt.ID).not.toEqual(sourceAttachmentID) + expect(newAtt.url).toBeTruthy() + expect(newAtt.filename).toEqual("sample.pdf") + expect(newAtt.mimeType).toBeTruthy() + expect(newAtt.hash).toBeTruthy() + // Scan status is inherited from source — no re-scan needed + expect(newAtt.status).toEqual("Clean") + + // Verify the copied record is in the DB under the target incident + const copied = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=true)/attachments`, + ) + expect(copied.status).toEqual(200) + expect(copied.data.value.length).toEqual(1) + expect(copied.data.value[0].ID).toEqual(newAtt.ID) + expect(copied.data.value[0].filename).toEqual("sample.pdf") + + // Verify content is downloadable + const contentResponse = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=true)/attachments(up__ID=${targetIncidentID},ID=${newAtt.ID},IsActiveEntity=true)/content`, + ) + expect(contentResponse.status).toEqual(200) + expect(contentResponse.data).toBeTruthy() + }) + + it("Copies an active attachment into a draft incident (active -> draft)", async () => { + const sourceIncidentID = await newIncident(POST, "processor") + const targetIncidentID = await newIncident(POST, "processor") // starts as draft + const sourceCleanWaiter = waitForScanStatus("Clean") + + const sourceAttachmentID = await uploadDraftAttachment( + utils, + POST, + GET, + sourceIncidentID, + ) + expect(sourceAttachmentID).toBeTruthy() + await sourceCleanWaiter + + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + // Look up the DraftUUID of the target incident's draft session + const targetDraft = await SELECT.one + .from(ProcessorService.entities.Incidents.drafts, { + ID: targetIncidentID, + }) + .columns("DraftAdministrativeData_DraftUUID") + expect(targetDraft?.DraftAdministrativeData_DraftUUID).toBeTruthy() + + const newAtt = await await runWithUser(alice, () => + AttachmentsSrv.copy( + Attachments, + { ID: sourceAttachmentID }, + Attachments.drafts, + { + up__ID: targetIncidentID, + DraftAdministrativeData_DraftUUID: + targetDraft.DraftAdministrativeData_DraftUUID, + }, + ), + ) + expect(newAtt.ID).toBeTruthy() + expect(newAtt.status).toEqual("Clean") + + // Verify the record exists in the draft table (IsActiveEntity=false) + const draftAttachments = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=false)/attachments`, + ) + expect(draftAttachments.status).toEqual(200) + expect(draftAttachments.data.value.length).toEqual(1) + expect(draftAttachments.data.value[0].ID).toEqual(newAtt.ID) + + // After saving the draft, the attachment should appear in the active entity + await utils.draftModeSave( + "processor", + "Incidents", + targetIncidentID, + "ProcessorService", + ) + const activeAttachments = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=true)/attachments`, + ) + expect(activeAttachments.status).toEqual(200) + expect(activeAttachments.data.value.length).toEqual(1) + expect(activeAttachments.data.value[0].ID).toEqual(newAtt.ID) + + // Content should be downloadable from the active entity + const contentResponse = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=true)/attachments(up__ID=${targetIncidentID},ID=${newAtt.ID},IsActiveEntity=true)/content`, + ) + expect(contentResponse.status).toEqual(200) + expect(contentResponse.data).toBeTruthy() + }) + + it("Copies a draft attachment into another draft incident (draft -> draft)", async () => { + const sourceIncidentID = await newIncident(POST, "processor") // draft + const targetIncidentID = await newIncident(POST, "processor") // draft + const sourceCleanWaiter = waitForScanStatus("Clean") + + // Upload to source as draft, then save it to active so it gets scanned + const sourceAttachmentID = await uploadDraftAttachment( + utils, + POST, + GET, + sourceIncidentID, + ) + expect(sourceAttachmentID).toBeTruthy() + await sourceCleanWaiter + + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + // Look up DraftUUID for target draft session + const targetDraft = await SELECT.one + .from(ProcessorService.entities.Incidents.drafts, { + ID: targetIncidentID, + }) + .columns("DraftAdministrativeData_DraftUUID") + expect(targetDraft?.DraftAdministrativeData_DraftUUID).toBeTruthy() + + // Source is the active Attachments entity (uploaded via draft, now active after save) + const newAtt = await await runWithUser(alice, () => + AttachmentsSrv.copy( + Attachments, + { ID: sourceAttachmentID }, + Attachments.drafts, + { + up__ID: targetIncidentID, + DraftAdministrativeData_DraftUUID: + targetDraft.DraftAdministrativeData_DraftUUID, + }, + ), + ) + expect(newAtt.ID).toBeTruthy() + expect(newAtt.status).toEqual("Clean") + + // Verify it is visible in draft context + const draftAttachments = await GET( + `odata/v4/processor/Incidents(ID=${targetIncidentID},IsActiveEntity=false)/attachments`, + ) + expect(draftAttachments.status).toEqual(200) + expect(draftAttachments.data.value.length).toEqual(1) + expect(draftAttachments.data.value[0].ID).toEqual(newAtt.ID) + }) + + it("Copy rejects attachment with Infected status", async () => { + const incidentID = await newIncident(POST, "processor") + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + // Directly insert a fake infected attachment record + const infectedID = cds.utils.uuid() + await cds.run( + INSERT({ + ID: infectedID, + url: cds.utils.uuid(), + filename: "infected.pdf", + mimeType: "application/pdf", + status: "Infected", + up__ID: incidentID, + }).into(Attachments), + ) + + await expect( + runWithUser(alice, () => + AttachmentsSrv.copy(Attachments, { ID: infectedID }, Attachments, { + up__ID: incidentID, + }), + ), + ).rejects.toMatchObject({ status: 400 }) + }) + + it("Copy rejects non-existent source attachment", async () => { + const incidentID = await newIncident(POST, "processor") + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + await expect( + runWithUser(alice, () => + AttachmentsSrv.copy( + Attachments, + { ID: cds.utils.uuid() }, + Attachments, + { + up__ID: incidentID, + }, + ), + ), + ).rejects.toMatchObject({ status: 404 }) + }) + + it("Copy strips protected fields from targetKeys", async () => { + const sourceIncidentID = await newIncident(POST, "processor") + const targetIncidentID = await newIncident(POST, "processor") + const sourceCleanWaiter = waitForScanStatus("Clean") + + const sourceAttachmentID = await uploadDraftAttachment( + utils, + POST, + GET, + sourceIncidentID, + ) + expect(sourceAttachmentID).toBeTruthy() + await utils.draftModeSave( + "processor", + "Incidents", + targetIncidentID, + "ProcessorService", + ) + await sourceCleanWaiter + + const AttachmentsSrv = await cds.connect.to("attachments") + const { ProcessorService } = cds.services + const Attachments = ProcessorService.entities["Incidents.attachments"] + + // Attempt to override protected fields via targetKeys + const newAtt = await runWithUser(alice, () => + AttachmentsSrv.copy( + Attachments, + { ID: sourceAttachmentID }, + Attachments, + { + up__ID: targetIncidentID, + status: "Unscanned", + hash: "tampered-hash", + filename: "evil.exe", + mimeType: "application/x-evil", + }, + ), + ) + + // Protected fields must reflect the source, not the attacker's values + expect(newAtt.status).toEqual("Clean") + expect(newAtt.hash).not.toEqual("tampered-hash") + expect(newAtt.filename).toEqual("sample.pdf") + expect(newAtt.mimeType).not.toEqual("application/x-evil") + }) +}) + /** * Uploads attachment in draft mode using CDS test utilities * @param {Object} utils - RequestSend utility instance diff --git a/tests/utils/testUtils.js b/tests/utils/testUtils.js index 3cf38f5a..81e71c2f 100644 --- a/tests/utils/testUtils.js +++ b/tests/utils/testUtils.js @@ -133,10 +133,20 @@ async function newIncident( } } +async function runWithUser(user, fn) { + const ctx = cds.EventContext.for({ + id: cds.utils.uuid(), + http: { req: null, res: null }, + }) + ctx.user = user + return cds._with(ctx, fn) +} + module.exports = { delay, waitForScanStatus, newIncident, waitForDeletion, waitForMalwareDeletion, + runWithUser, }