diff --git a/db/index.cds b/db/index.cds index 59448cc1..5045b415 100644 --- a/db/index.cds +++ b/db/index.cds @@ -1,6 +1,7 @@ // The common root-level aspect used in applications like that: // using { Attachments } from '@cap-js/attachments' aspect Attachments : sap.attachments.Attachments {} +type Attachment : sap.attachments.SingleMediaData; using { managed, @@ -10,6 +11,16 @@ using { context sap.attachments { + type SingleMediaData @(_is_media_data) { + url : String @UI.Hidden; + content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services + mimeType : String default 'application/octet-stream' @title: '{i18n>MediaType}'; + filename : String @title: '{i18n>FileName}'; + hash : String @UI.Hidden @Core.Computed; + status : Association to one ScanStates default 'Unscanned' @title: '{i18n>ScanStatus}' @Common.Text: status.name @Common.TextArrangement: #TextOnly; + lastScan : Timestamp @title: '{i18n>LastScan}' @Core.Computed; + } + aspect MediaData @(_is_media_data) { url : String @UI.Hidden; content : LargeBinary @title: '{i18n>Attachment}'; // only for db-based services diff --git a/srv/basic.js b/srv/basic.js index 86d3aba8..1d6157d0 100644 --- a/srv/basic.js +++ b/srv/basic.js @@ -368,11 +368,13 @@ class AttachmentsService extends cds.Service { this.traverseDataByPath(active, attachmentsComp) || [] const draftAttachments = this.traverseDataByPath(draft, attachmentsComp) || [] - const draftAttachmentIDs = new Set(draftAttachments.map((a) => a.ID)) + const draftAttachmentIDs = new Set( + draftAttachments.filter((a) => a.ID).map((a) => a.ID), + ) // Find attachments present in the active entity but not in the draft const deletedAttachments = activeAttachments.filter( - (att) => att.url && !draftAttachmentIDs.has(att.ID), + (att) => att.url && att.ID && !draftAttachmentIDs.has(att.ID), ) const entityTarget = traverseEntity(req.target, attachmentsComp) diff --git a/tests/incidents-app/app/incidents/annotations.cds b/tests/incidents-app/app/incidents/annotations.cds index 4c856500..429a7834 100644 --- a/tests/incidents-app/app/incidents/annotations.cds +++ b/tests/incidents-app/app/incidents/annotations.cds @@ -244,4 +244,30 @@ annotate service.TestDetails with @( Target : 'attachments/@UI.LineItem' } ] +); + +annotate service.SingleAttachment with @( + UI.LineItem : [ + { + Value : name, + Label : 'Name', + }, + ], + UI.Facets : [ + { + $Type : 'UI.ReferenceFacet', + Label : 'General Information', + Target : '@UI.FieldGroup#General', + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Attachment', + Target : 'myAttachment/@UI.LineItem' + } + ], + UI.FieldGroup #General : { + Data : [ + { Value : name }, + ] + } ); \ No newline at end of file diff --git a/tests/incidents-app/db/attachments.cds b/tests/incidents-app/db/attachments.cds index cf27cb33..3f9fe31a 100644 --- a/tests/incidents-app/db/attachments.cds +++ b/tests/incidents-app/db/attachments.cds @@ -1,5 +1,5 @@ using {sap.capire.incidents as my} from './schema'; -using {Attachments} from '@cap-js/attachments'; +using {Attachments, Attachment} from '@cap-js/attachments'; extend my.Incidents with { @Validation.MaxItems: 2 @@ -54,3 +54,7 @@ extend my.NonDraftTest with { extend my.SingleTestDetails with { attachments : Composition of many Attachments; } + +extend my.SingleAttachment with { + myAttachment : Attachment; +} diff --git a/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv b/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv new file mode 100644 index 00000000..b239f760 --- /dev/null +++ b/tests/incidents-app/db/data/sap.capire.incidents-SingleAttachment.csv @@ -0,0 +1,3 @@ +ID,name +a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d,My single attachment +b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e,My other attachment \ No newline at end of file diff --git a/tests/incidents-app/db/schema.cds b/tests/incidents-app/db/schema.cds index 438e3d3f..e622aebc 100644 --- a/tests/incidents-app/db/schema.cds +++ b/tests/incidents-app/db/schema.cds @@ -100,3 +100,7 @@ entity NonDraftTest : cuid, managed { entity SingleTestDetails : cuid { abc : String; } + +entity SingleAttachment : cuid { + name : String; +} diff --git a/tests/incidents-app/srv/services.cds b/tests/incidents-app/srv/services.cds index a7e0ba1a..0e17e89b 100644 --- a/tests/incidents-app/srv/services.cds +++ b/tests/incidents-app/srv/services.cds @@ -22,6 +22,9 @@ service ProcessorService { entity SingleTestDetails as projection on my.SingleTestDetails; + @odata.draft.enabled + entity SingleAttachment as projection on my.SingleAttachment; + action insertTestData() returns String; } diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 78551de8..eaf7b1ac 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -1235,61 +1235,157 @@ describe("Tests for uploading/deleting attachments through API calls", () => { expect(getRes2.data.url).toBeTruthy() }) - isNotLocal( - "Should detect infected files and automatically delete them after scan", - async () => { - const incidentID = await newIncident(POST, "processor") - const testMal = - "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=" - const fileContent = Buffer.from(testMal, "base64").toString("utf8") - - const scanInfectedWaiter = waitForScanStatus("Infected") - - await utils.draftModeEdit( - "processor", - "Incidents", - incidentID, - "ProcessorService", - ) - const res = await POST( - `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=false)/attachments`, - { - up__ID: incidentID, - filename: "testmal.png", - mimeType: "image/png", - createdAt: new Date(), - createdBy: "alice", + // prettier-ignore + isNotLocal("Should detect infected files and automatically delete them after scan", async () => { + const incidentID = await newIncident(POST, "processor") + const testMal = + "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=" + const fileContent = Buffer.from(testMal, "base64").toString("utf8") + + const scanInfectedWaiter = waitForScanStatus("Infected") + + await utils.draftModeEdit( + "processor", + "Incidents", + incidentID, + "ProcessorService", + ) + const res = await POST( + `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=false)/attachments`, + { + up__ID: incidentID, + filename: "testmal.png", + mimeType: "image/png", + createdAt: new Date(), + createdBy: "alice", + }, + ) + expect(res.data.ID).toBeTruthy() + + const deletionWaiter = waitForMalwareDeletion(res.data.ID) + + await PUT( + `/odata/v4/processor/Incidents_attachments(up__ID=${incidentID},ID=${res.data.ID},IsActiveEntity=false)/content`, + fileContent, + { + headers: { + "Content-Type": "image/png", + "Content-Length": fileContent.length, }, - ) - expect(res.data.ID).toBeTruthy() + }, + ) + + await utils.draftModeSave( + "processor", + "Incidents", + incidentID, + "ProcessorService", + ) - const deletionWaiter = waitForMalwareDeletion(res.data.ID) + // Check that status is "infected" after scan + await scanInfectedWaiter - await PUT( - `/odata/v4/processor/Incidents_attachments(up__ID=${incidentID},ID=${res.data.ID},IsActiveEntity=false)/content`, - fileContent, - { - headers: { - "Content-Type": "image/png", - "Content-Length": fileContent.length, - }, + // Wait for deletion to complete + await deletionWaiter + }) +}) + +describe("Tests for single attachment entity", () => { + it("should create a SingleAttachment with an attachment", async () => { + const { data: singleAttachment } = await POST( + "/odata/v4/processor/SingleAttachment", + { + name: "My Single Attachment Test", + }, + ) + expect(singleAttachment.ID).toBeDefined() + expect(singleAttachment.name).toBe("My Single Attachment Test") + + const filepath = join(__dirname, "content/sample.pdf") + const fileContent = readFileSync(filepath) + + const putRes = await PUT( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { + filename: basename(filepath), + mimeType: "application/pdf", + }, + ) + expect(putRes.status).toEqual(200) + + const putContentRes = await PUT( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment/content`, + fileContent, + { + headers: { + "Content-Type": "application/pdf", }, - ) + }, + ) + expect(putContentRes.status).toEqual(204) - await utils.draftModeSave( - "processor", - "Incidents", - incidentID, - "ProcessorService", - ) + // Activate the draft + await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`, + ) - // Check that status is "infected" after scan - await scanInfectedWaiter + // Verify active document + const getRes = await GET( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`, + ) + expect(getRes.data.filename).toBe(basename(filepath)) + }) - // Wait for deletion to complete - await deletionWaiter - }, - ) + it("should delete a SingleAttachment and its attachment", async () => { + // Create a new entity to delete + const { data: singleAttachment } = await POST( + "/odata/v4/processor/SingleAttachment", + { + name: "Entity to be deleted", + }, + ) + const filepath = join(__dirname, "content/sample.pdf") + const fileContent = readFileSync(filepath) + + await PUT( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment`, + { + filename: basename(filepath), + mimeType: "application/pdf", + }, + ) + + await PUT( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/myAttachment/content`, + fileContent, + { headers: { "Content-Type": "application/pdf" } }, + ) + + await POST( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=false)/ProcessorService.draftActivate`, + ) + + const activeAttachment = await GET( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)/myAttachment`, + ) + const attachmentUrl = activeAttachment.data.url + expect(attachmentUrl).toBeDefined() + + // Now, delete the parent entity + const deleteRes = await DELETE( + `/odata/v4/processor/SingleAttachment(ID=${singleAttachment.ID},IsActiveEntity=true)`, + ) + expect(deleteRes.status).toEqual(204) + + // Verify the attachment content is gone from the object store (which is the DB in this test setup) + const db = await cds.connect.to("db") + const content = await db.run( + SELECT.one + .from("sap.attachments.Attachments") + .where({ url: attachmentUrl }), + ) + expect(content.content).toBeNull() + }) }) describe("Tests for attachments facet disable", () => {