Skip to content
Draft
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
11 changes: 11 additions & 0 deletions db/index.cds
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions srv/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions tests/incidents-app/app/incidents/annotations.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
}
);
6 changes: 5 additions & 1 deletion tests/incidents-app/db/attachments.cds
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -54,3 +54,7 @@ extend my.NonDraftTest with {
extend my.SingleTestDetails with {
attachments : Composition of many Attachments;
}

extend my.SingleAttachment with {
myAttachment : Attachment;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ID,name
a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d,My single attachment
b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e,My other attachment
4 changes: 4 additions & 0 deletions tests/incidents-app/db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,7 @@ entity NonDraftTest : cuid, managed {
entity SingleTestDetails : cuid {
abc : String;
}

entity SingleAttachment : cuid {
name : String;
}
3 changes: 3 additions & 0 deletions tests/incidents-app/srv/services.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
192 changes: 144 additions & 48 deletions tests/integration/attachments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading