From 5a7bddb2b98b87f042aeb6e4a61e693a130173cf Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 15:05:36 +0200 Subject: [PATCH 1/6] Send out websocket event for malware scanning status change --- CHANGELOG.md | 4 + README.md | 3 + lib/plugin.js | 132 ++++++++---- srv/attachments/basic.js | 18 +- srv/malware-scanner/malwareScanner.js | 24 +++ tests/incidents-app/package.json | 1 + tests/incidents-app/srv/services.cds | 6 + tests/unit/websocket.test.js | 278 ++++++++++++++++++++++++++ 8 files changed, 416 insertions(+), 50 deletions(-) create mode 100644 tests/unit/websocket.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0655ac17..efb3f598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Unreleased +### Added + +- Send out `attachmentStatusChanged` websocket event when the malware scanner finishes scanning. This requires websockets and draft being enabled. + ### Fixed - Security audit events (`AttachmentSizeExceeded`, `AttachmentUploadRejected`, `AttachmentDownloadRejected`) now log the real client IP on reverse-proxy deployments (e.g. BTP Cloud Foundry) as well by setting `X-Forwarded-For` as an attribute on the audit log. diff --git a/README.md b/README.md index 00cb36a2..02bba1d7 100755 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ Scan status codes: - `Infected`: The attachment is infected. - `Failed`: Scanning failed. +> [!Tip] +> When a service is [enabled for WebSockets](https://cap.cloud.sap/docs/plugins/#websocket) and the entity with attachments is draft-enabled, the plugin automatically emits an `attachmentStatusChanged` WebSocket event once malware scanning completes. This means the SAP Fiori Elements UI will automatically refresh once scanning is complete. + > [!Note] > The malware scanner supports mTLS authentication which requires an annual renewal of the certificate. Previously, basic authentication was used which has now been deprecated. diff --git a/lib/plugin.js b/lib/plugin.js index 5c37d630..79f59f41 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -16,6 +16,7 @@ require("./csn-runtime-extension") const LOG = cds.log("attachments") cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel) +cds.on("compile.for.runtime", unfoldModel) // Register the db handler ONCE (not per-service) to intercept attachment INSERT // and handle it through the attachments service instead of native DB insert @@ -64,61 +65,108 @@ cds.once("served", () => { }) /** - * Unfold the model to add necessary facets for attachments + * Unfold the model to add necessary facets, events and annotations for attachments * @param {*} csn - CSN model */ function unfoldModel(csn) { const meta = (csn.meta ??= {}) if (!("sap.attachments.Attachments" in csn.definitions)) return if (meta._enhanced_for_attachments) return - // const csnCopy = structuredClone(csn) // REVISIT: Why did we add this cloning? - const hasFacetForComp = (comp, facets) => - facets.some( - (f) => - f.Target === `${comp.name}/@UI.LineItem` || - (f.Facets && hasFacetForComp(comp, f.Facets)), - ) - cds.linked(csn).forall("Composition", (comp) => { - if ( - comp._target && - comp._target["@_is_media_data"] && - comp.parent && - comp.is2many - ) { - let facets = comp.parent["@UI.Facets"] - if (!facets) return - if (comp["@attachments.disable_facet"] !== undefined) { - LOG.warn( - `@attachments.disable_facet is deprecated! Please annotate ${comp.name} with @UI.Hidden`, - ) - } + + const servicesWithAttachments = new Set() + + if (meta.flavor === "inferred") { + const hasFacetForComp = (comp, facets) => + facets.some( + (f) => + f.Target === `${comp.name}/@UI.LineItem` || + (f.Facets && hasFacetForComp(comp, f.Facets)), + ) + cds.linked(csn).forall("Composition", (comp) => { if ( - !comp["@attachments.disable_facet"] && - !hasFacetForComp(comp, facets) + comp._target && + comp._target["@_is_media_data"] && + comp.parent && + comp.is2many ) { - LOG.debug(`Adding @UI.Facet to: ${comp.parent.name}`) - const attachmentsFacet = { - $Type: "UI.ReferenceFacet", - Target: `${comp.name}/@UI.LineItem`, - ID: `${comp.name}_attachments`, - Label: "{i18n>Attachments}", + // Track services that have attachment compositions + const srvName = comp._target._service?.name + if (srvName) { + servicesWithAttachments.add(srvName) } - if (comp["@UI.Hidden"]) { - attachmentsFacet["@UI.Hidden"] = comp["@UI.Hidden"] + + // Add @Common.SideEffects annotation to attachment entity + const targetDef = csn.definitions[comp._target.name] + if (targetDef && !targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"]) { + targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] = ["attachmentStatusChanged"] + targetDef["@Common.SideEffects#attachmentStatusChanged.TargetProperties"] = ["status"] + targetDef["@Common.SideEffects#attachmentStatusChanged.TargetEntities"] = [{ "=": "statusNav" }] } - facets.push(attachmentsFacet) - //Hide parent key so it cannot be selected from Columns on the UI - Object.keys(comp._target.elements) - .filter((e) => e.startsWith("up__")) - .forEach((ele) => { - comp._target.elements[ele]["@UI.Hidden"] = true - }) - if (comp._target.elements["up_"]) { - comp._target.elements["up_"]["@UI.Hidden"] = true + + let facets = comp.parent["@UI.Facets"] + if (!facets) return + if (comp["@attachments.disable_facet"] !== undefined) { + LOG.warn( + `@attachments.disable_facet is deprecated! Please annotate ${comp.name} with @UI.Hidden`, + ) + } + if ( + !comp["@attachments.disable_facet"] && + !hasFacetForComp(comp, facets) + ) { + LOG.debug(`Adding @UI.Facet to: ${comp.parent.name}`) + const attachmentsFacet = { + $Type: "UI.ReferenceFacet", + Target: `${comp.name}/@UI.LineItem`, + ID: `${comp.name}_attachments`, + Label: "{i18n>Attachments}", + } + if (comp["@UI.Hidden"]) { + attachmentsFacet["@UI.Hidden"] = comp["@UI.Hidden"] + } + facets.push(attachmentsFacet) + //Hide parent key so it cannot be selected from Columns on the UI + Object.keys(comp._target.elements) + .filter((e) => e.startsWith("up__")) + .forEach((ele) => { + comp._target.elements[ele]["@UI.Hidden"] = true + }) + if (comp._target.elements["up_"]) { + comp._target.elements["up_"]["@UI.Hidden"] = true + } + } + } + }) + } else { + // For non-inferred CSN (e.g. compile.for.runtime), find services with attachment compositions + for (const [name, def] of Object.entries(csn.definitions)) { + if (def["@_is_media_data"] && def.kind === "entity") { + const parts = name.split(".") + // Service name is the first part for service-scoped entities + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(0, i).join(".") + if (csn.definitions[candidate]?.kind === "service") { + servicesWithAttachments.add(candidate) + break + } } } } - }) + } + + // Add attachmentStatusChanged event to services that have attachment compositions + for (const srvName of servicesWithAttachments) { + const eventName = `${srvName}.attachmentStatusChanged` + if (!csn.definitions[eventName]) { + csn.definitions[eventName] = { + kind: "event", + elements: { + sideEffectSource: { type: "cds.String" }, + }, + } + } + } + meta._enhanced_for_attachments = true } diff --git a/srv/attachments/basic.js b/srv/attachments/basic.js index 092f447a..2c9af63c 100644 --- a/srv/attachments/basic.js +++ b/srv/attachments/basic.js @@ -172,14 +172,16 @@ class AttachmentsService extends cds.Service { }) const MalwareScanner = await cds.connect.to("malwareScanner") - await Promise.all( - data.map(async (d) => { - await MalwareScanner.emit("ScanAttachmentsFile", { - target: attachments.name, - keys: { ID: d.ID }, - }) - }), - ) + cds.spawn(async () => { + await Promise.all( + data.map(async (d) => { + await MalwareScanner.emit("ScanAttachmentsFile", { + target: attachments.name, + keys: { ID: d.ID }, + }) + }), + ) + }) return res } diff --git a/srv/malware-scanner/malwareScanner.js b/srv/malware-scanner/malwareScanner.js index 2862e4ab..656c19a3 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -199,6 +199,30 @@ class MalwareScanner extends cds.ApplicationService { UPDATE.entity(target).where(where).set(updateObject), UPDATE.entity(target.drafts).where(where).set(updateObject), ]) + cds.spawn({}, async () => { + const [active, draft] = await Promise.all([ + SELECT.one.from(target).where(where), + SELECT.one.from(target.drafts).where(where), + ]) + const parentEntity = target.elements.up_?._target + if (parentEntity?._service) { + const srv = await cds.connect.to(parentEntity._service.name) + const parentEntityName = parentEntity.name.split(".").pop() + const compositionName = target.name.slice(parentEntity.name.length + 1) + const result = draft ?? active + if (result) { + const isActiveEntity = !draft + const parentKeys = Object.keys(result) + .filter((k) => k.startsWith("up__")) + .map((k) => `${k.slice(4)}=${result[k]}`) + .join(",") + const attachmentID = result.ID + srv.emit("attachmentStatusChanged", { + sideEffectSource: `/${parentEntityName}(${parentKeys},IsActiveEntity=${isActiveEntity})/${compositionName}(ID=${attachmentID},IsActiveEntity=${isActiveEntity})`, + }) + } + } + }) } else { await UPDATE.entity(target).where(where).set(updateObject) } diff --git a/tests/incidents-app/package.json b/tests/incidents-app/package.json index c010759f..db6ea74a 100644 --- a/tests/incidents-app/package.json +++ b/tests/incidents-app/package.json @@ -2,6 +2,7 @@ "name": "@capire/incidents", "version": "1.0.0", "dependencies": { + "@cap-js-community/websocket": "^1.10.1", "@cap-js/attachments": "file:../../.", "@cap-js/audit-logging": "^1.2.0", "@cap-js/hana": "2.6.0", diff --git a/tests/incidents-app/srv/services.cds b/tests/incidents-app/srv/services.cds index 7072ec74..631c1a7c 100644 --- a/tests/incidents-app/srv/services.cds +++ b/tests/incidents-app/srv/services.cds @@ -4,6 +4,12 @@ using from '../db/attachments'; /** * Service used by support personell, i.e. the incidents' 'processors'. */ +@ws +@odata +@Common: { + WebSocketBaseURL: 'ws/processor', + WebSocketChannel #sideEffects: 'sideeffects', +} service ProcessorService { @cds.redirection.target entity Incidents as projection on my.Incidents actions { diff --git a/tests/unit/websocket.test.js b/tests/unit/websocket.test.js new file mode 100644 index 00000000..e0b3fd04 --- /dev/null +++ b/tests/unit/websocket.test.js @@ -0,0 +1,278 @@ +require("../../lib/csn-runtime-extension") +const cds = require("@sap/cds") +const { join } = cds.utils.path +cds.test(join(__dirname, "../incidents-app")) + +const MalwareScanner = require("../../srv/malware-scanner/malwareScanner") + +let scanner +let processorSrv +let spawnCallback + +beforeEach(() => { + jest.clearAllMocks() + + cds.env.requires.attachments = { scan: true } + cds.env.requires.malwareScanner = { + credentials: { uri: "host", certificate: "C", key: "K" }, + } + + jest.spyOn(cds, "context", "get").mockReturnValue({ model: cds.model }) + + processorSrv = { emit: jest.fn().mockResolvedValue(undefined) } + + cds.connect.to = jest.fn().mockImplementation(async (name) => { + if (name === "ProcessorService") return processorSrv + return { emit: jest.fn(), get: jest.fn() } + }) + + // Capture the cds.spawn callback so we can execute it synchronously in tests + spawnCallback = null + jest.spyOn(cds, "spawn").mockImplementation((_opts, fn) => { + spawnCallback = fn + return { on: jest.fn() } + }) + + scanner = new MalwareScanner() + scanner.retryConfig = { + enabled: false, + maxAttempts: 1, + initialDelay: 1000, + maxDelay: 30000, + } + scanner.scan = jest.fn() +}) + +// --------------------------------------------------------------------------- +// updateStatus — WebSocket event emission (attachmentStatusChanged) +// --------------------------------------------------------------------------- + +describe("updateStatus - attachmentStatusChanged event emission", () => { + const _target = + () => cds.model.definitions["ProcessorService.Incidents.attachments"] + + it("emits attachmentStatusChanged with draft path when draft exists", async () => { + const target = _target() + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + + await INSERT.into(target).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + }) + await INSERT.into(target.drafts).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + DraftAdministrativeData_DraftUUID: cds.utils.uuid(), + }) + + await scanner.updateStatus(target, keys, "Clean") + + expect(cds.spawn).toHaveBeenCalled() + await spawnCallback() + + expect(processorSrv.emit).toHaveBeenCalledWith( + "attachmentStatusChanged", + expect.objectContaining({ + sideEffectSource: expect.stringContaining("IsActiveEntity=false"), + }), + ) + + const emitCall = processorSrv.emit.mock.calls[0] + const source = emitCall[1].sideEffectSource + expect(source).toMatch( + new RegExp( + `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=false\\)/attachments\\(ID=${keys.ID},IsActiveEntity=false\\)$`, + ), + ) + }) + + it("emits attachmentStatusChanged with active path when only active exists", async () => { + const target = _target() + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + + // Only insert into active, no draft + await INSERT.into(target).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + }) + + await scanner.updateStatus(target, keys, "Clean") + + expect(cds.spawn).toHaveBeenCalled() + await spawnCallback() + + expect(processorSrv.emit).toHaveBeenCalledWith( + "attachmentStatusChanged", + expect.objectContaining({ + sideEffectSource: expect.stringContaining("IsActiveEntity=true"), + }), + ) + + const emitCall = processorSrv.emit.mock.calls[0] + const source = emitCall[1].sideEffectSource + expect(source).toMatch( + new RegExp( + `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=true\\)/attachments\\(ID=${keys.ID},IsActiveEntity=true\\)$`, + ), + ) + }) + + it("does not emit when neither active nor draft exists", async () => { + const target = _target() + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + + // Insert then delete to ensure rows don't exist + await INSERT.into(target).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + }) + await DELETE.from(target).where(keys) + + await scanner.updateStatus(target, keys, "Clean") + + expect(cds.spawn).toHaveBeenCalled() + await spawnCallback() + + expect(processorSrv.emit).not.toHaveBeenCalled() + }) + + it("does not emit or spawn for non-draft entities", async () => { + const target = cds.model.definitions["AdminService.Incidents.attachments"] + expect(target.drafts).toBeFalsy() + + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + await INSERT.into(target).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + }) + + await scanner.updateStatus(target, keys, "Clean") + + expect(cds.spawn).not.toHaveBeenCalled() + expect(processorSrv.emit).not.toHaveBeenCalled() + }) + + it("constructs correct sideEffectSource path with parent entity and composition name", async () => { + const target = _target() + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + + await INSERT.into(target.drafts).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + DraftAdministrativeData_DraftUUID: cds.utils.uuid(), + }) + + await scanner.updateStatus(target, keys, "Scanning") + + expect(cds.spawn).toHaveBeenCalled() + await spawnCallback() + + const emitCall = processorSrv.emit.mock.calls[0] + const source = emitCall[1].sideEffectSource + + // Should start with /Incidents (parent entity short name) + expect(source).toMatch(/^\/Incidents\(/) + // Should contain /attachments( (composition name) + expect(source).toContain("/attachments(") + // Should contain parent key + expect(source).toContain(`ID=${keys.up__ID}`) + // Should contain attachment ID + expect(source).toContain(`ID=${keys.ID}`) + }) + + it("connects to correct service derived from parent entity", async () => { + const target = _target() + const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } + + await INSERT.into(target.drafts).entries({ + ...keys, + status: "Scanning", + filename: "test.pdf", + DraftAdministrativeData_DraftUUID: cds.utils.uuid(), + }) + + await scanner.updateStatus(target, keys, "Clean") + + expect(cds.spawn).toHaveBeenCalled() + await spawnCallback() + + expect(cds.connect.to).toHaveBeenCalledWith("ProcessorService") + }) +}) + +// --------------------------------------------------------------------------- +// unfoldModel — auto-generated event and SideEffects annotation +// --------------------------------------------------------------------------- + +describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { + it("adds attachmentStatusChanged event to services with attachment compositions", () => { + const eventDef = + cds.model.definitions["ProcessorService.attachmentStatusChanged"] + expect(eventDef).toBeDefined() + expect(eventDef.kind).toBe("event") + expect(eventDef.elements.sideEffectSource).toBeDefined() + expect(eventDef.elements.sideEffectSource.type).toBe("cds.String") + }) + + it("adds attachmentStatusChanged event to all services with attachments", () => { + const services = [ + "ProcessorService", + "AdminService", + "ValidationTestService", + "ValidationTestNonDraftService", + "RestrictionService", + ] + for (const srv of services) { + const eventDef = + cds.model.definitions[`${srv}.attachmentStatusChanged`] + expect(eventDef).toBeDefined() + expect(eventDef.kind).toBe("event") + } + }) + + it("adds @Common.SideEffects annotation to attachment entities", () => { + const attachments = + cds.model.definitions["ProcessorService.Incidents.attachments"] + expect( + attachments["@Common.SideEffects#attachmentStatusChanged.SourceEvents"], + ).toEqual(["attachmentStatusChanged"]) + expect( + attachments["@Common.SideEffects#attachmentStatusChanged.TargetProperties"], + ).toEqual(["status"]) + expect( + attachments["@Common.SideEffects#attachmentStatusChanged.TargetEntities"], + ).toEqual([{ "=": "statusNav" }]) + }) + + it("adds SideEffects to all attachment composition targets", () => { + const entities = [ + "ProcessorService.Incidents.attachments", + "ProcessorService.Incidents.hiddenAttachments", + "ProcessorService.Customers.attachments", + "ProcessorService.SampleRootWithComposedEntity.attachments", + "ProcessorService.Test.attachments", + ] + for (const name of entities) { + const def = cds.model.definitions[name] + expect( + def["@Common.SideEffects#attachmentStatusChanged.SourceEvents"], + ).toEqual(["attachmentStatusChanged"]) + } + }) + + it("does not add duplicate SideEffects if already present", async () => { + // The model is already loaded and enhanced; verify no duplication + const attachments = + cds.model.definitions["ProcessorService.Incidents.attachments"] + const sourceEvents = + attachments["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] + expect(sourceEvents).toEqual(["attachmentStatusChanged"]) + expect(sourceEvents).toHaveLength(1) + }) +}) From bc1ad452d5e86d2a0cb87a5365d7203fb388684d Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 15:05:54 +0200 Subject: [PATCH 2/6] Formatting --- lib/plugin.js | 17 +++++++++++++---- srv/malware-scanner/malwareScanner.js | 4 +++- tests/unit/websocket.test.js | 11 ++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/lib/plugin.js b/lib/plugin.js index 79f59f41..04a8189a 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -97,10 +97,19 @@ function unfoldModel(csn) { // Add @Common.SideEffects annotation to attachment entity const targetDef = csn.definitions[comp._target.name] - if (targetDef && !targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"]) { - targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] = ["attachmentStatusChanged"] - targetDef["@Common.SideEffects#attachmentStatusChanged.TargetProperties"] = ["status"] - targetDef["@Common.SideEffects#attachmentStatusChanged.TargetEntities"] = [{ "=": "statusNav" }] + if ( + targetDef && + !targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] + ) { + targetDef[ + "@Common.SideEffects#attachmentStatusChanged.SourceEvents" + ] = ["attachmentStatusChanged"] + targetDef[ + "@Common.SideEffects#attachmentStatusChanged.TargetProperties" + ] = ["status"] + targetDef[ + "@Common.SideEffects#attachmentStatusChanged.TargetEntities" + ] = [{ "=": "statusNav" }] } let facets = comp.parent["@UI.Facets"] diff --git a/srv/malware-scanner/malwareScanner.js b/srv/malware-scanner/malwareScanner.js index 656c19a3..a1bef961 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -208,7 +208,9 @@ class MalwareScanner extends cds.ApplicationService { if (parentEntity?._service) { const srv = await cds.connect.to(parentEntity._service.name) const parentEntityName = parentEntity.name.split(".").pop() - const compositionName = target.name.slice(parentEntity.name.length + 1) + const compositionName = target.name.slice( + parentEntity.name.length + 1, + ) const result = draft ?? active if (result) { const isActiveEntity = !draft diff --git a/tests/unit/websocket.test.js b/tests/unit/websocket.test.js index e0b3fd04..7c9b0b27 100644 --- a/tests/unit/websocket.test.js +++ b/tests/unit/websocket.test.js @@ -48,8 +48,8 @@ beforeEach(() => { // --------------------------------------------------------------------------- describe("updateStatus - attachmentStatusChanged event emission", () => { - const _target = - () => cds.model.definitions["ProcessorService.Incidents.attachments"] + const _target = () => + cds.model.definitions["ProcessorService.Incidents.attachments"] it("emits attachmentStatusChanged with draft path when draft exists", async () => { const target = _target() @@ -229,8 +229,7 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { "RestrictionService", ] for (const srv of services) { - const eventDef = - cds.model.definitions[`${srv}.attachmentStatusChanged`] + const eventDef = cds.model.definitions[`${srv}.attachmentStatusChanged`] expect(eventDef).toBeDefined() expect(eventDef.kind).toBe("event") } @@ -243,7 +242,9 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { attachments["@Common.SideEffects#attachmentStatusChanged.SourceEvents"], ).toEqual(["attachmentStatusChanged"]) expect( - attachments["@Common.SideEffects#attachmentStatusChanged.TargetProperties"], + attachments[ + "@Common.SideEffects#attachmentStatusChanged.TargetProperties" + ], ).toEqual(["status"]) expect( attachments["@Common.SideEffects#attachmentStatusChanged.TargetEntities"], From ef8098a65f9fcbceebd79e58c49375f4523a3aba Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 15:15:25 +0200 Subject: [PATCH 3/6] Small adjustment --- srv/attachments/basic.js | 2 +- srv/malware-scanner/malwareScanner.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/srv/attachments/basic.js b/srv/attachments/basic.js index 2c9af63c..a209a165 100644 --- a/srv/attachments/basic.js +++ b/srv/attachments/basic.js @@ -172,7 +172,7 @@ class AttachmentsService extends cds.Service { }) const MalwareScanner = await cds.connect.to("malwareScanner") - cds.spawn(async () => { + cds.spawn({}, async () => { await Promise.all( data.map(async (d) => { await MalwareScanner.emit("ScanAttachmentsFile", { diff --git a/srv/malware-scanner/malwareScanner.js b/srv/malware-scanner/malwareScanner.js index a1bef961..2c17001e 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -199,7 +199,7 @@ class MalwareScanner extends cds.ApplicationService { UPDATE.entity(target).where(where).set(updateObject), UPDATE.entity(target.drafts).where(where).set(updateObject), ]) - cds.spawn({}, async () => { + const broadcastUpdate = cds.spawn({}, async () => { const [active, draft] = await Promise.all([ SELECT.one.from(target).where(where), SELECT.one.from(target.drafts).where(where), @@ -225,6 +225,7 @@ class MalwareScanner extends cds.ApplicationService { } } }) + broadcastUpdate.on('failed', (error) => {LOG.debug(`Emitting websocket event attachmentStatusChanged failed with: `, error)}) } else { await UPDATE.entity(target).where(where).set(updateObject) } From 60837e7dedff9d426b1b49d74328e15ca7310cfd Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 15:15:44 +0200 Subject: [PATCH 4/6] Update malwareScanner.js --- srv/malware-scanner/malwareScanner.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/srv/malware-scanner/malwareScanner.js b/srv/malware-scanner/malwareScanner.js index 2c17001e..7a68b111 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -225,7 +225,12 @@ class MalwareScanner extends cds.ApplicationService { } } }) - broadcastUpdate.on('failed', (error) => {LOG.debug(`Emitting websocket event attachmentStatusChanged failed with: `, error)}) + broadcastUpdate.on("failed", (error) => { + LOG.debug( + `Emitting websocket event attachmentStatusChanged failed with: `, + error, + ) + }) } else { await UPDATE.entity(target).where(where).set(updateObject) } From 0cb62fb47994ed11bf5723cf4467143d033feba3 Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 16:16:55 +0200 Subject: [PATCH 5/6] Working --- lib/plugin.js | 29 +++++----- srv/malware-scanner/malwareScanner.js | 6 +- tests/integration/attachments.test.js | 16 ++++++ tests/unit/websocket.test.js | 79 ++++++++++++++------------- 4 files changed, 72 insertions(+), 58 deletions(-) diff --git a/lib/plugin.js b/lib/plugin.js index 04a8189a..95a5bcf4 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -71,7 +71,6 @@ cds.once("served", () => { function unfoldModel(csn) { const meta = (csn.meta ??= {}) if (!("sap.attachments.Attachments" in csn.definitions)) return - if (meta._enhanced_for_attachments) return const servicesWithAttachments = new Set() @@ -95,21 +94,20 @@ function unfoldModel(csn) { servicesWithAttachments.add(srvName) } - // Add @Common.SideEffects annotation to attachment entity - const targetDef = csn.definitions[comp._target.name] + // Add @Common.SideEffects annotation to parent entity targeting attachment composition + // Only annotate service-level entities (they appear in $metadata) + // REVISIT: Once FE supports row level targeting for these, adjust that not the whole attachments table is refreshed + const parentDef = csn.definitions[comp.parent.name] + const qualifier = `attachmentStatusChanged_${comp.name}` if ( - targetDef && - !targetDef["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] + parentDef && + comp._target._service && + !parentDef[`@Common.SideEffects#${qualifier}`] ) { - targetDef[ - "@Common.SideEffects#attachmentStatusChanged.SourceEvents" - ] = ["attachmentStatusChanged"] - targetDef[ - "@Common.SideEffects#attachmentStatusChanged.TargetProperties" - ] = ["status"] - targetDef[ - "@Common.SideEffects#attachmentStatusChanged.TargetEntities" - ] = [{ "=": "statusNav" }] + parentDef[`@Common.SideEffects#${qualifier}`] = { + SourceEvents: ["attachmentStatusChanged"], + TargetEntities: [{ "=": comp.name }], + } } let facets = comp.parent["@UI.Facets"] @@ -169,6 +167,9 @@ function unfoldModel(csn) { if (!csn.definitions[eventName]) { csn.definitions[eventName] = { kind: "event", + "@ws": true, + "@ws.format": "pcp", + "@ws.pcp.sideEffect": true, elements: { sideEffectSource: { type: "cds.String" }, }, diff --git a/srv/malware-scanner/malwareScanner.js b/srv/malware-scanner/malwareScanner.js index 7a68b111..690a002f 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -208,9 +208,6 @@ class MalwareScanner extends cds.ApplicationService { if (parentEntity?._service) { const srv = await cds.connect.to(parentEntity._service.name) const parentEntityName = parentEntity.name.split(".").pop() - const compositionName = target.name.slice( - parentEntity.name.length + 1, - ) const result = draft ?? active if (result) { const isActiveEntity = !draft @@ -218,9 +215,8 @@ class MalwareScanner extends cds.ApplicationService { .filter((k) => k.startsWith("up__")) .map((k) => `${k.slice(4)}=${result[k]}`) .join(",") - const attachmentID = result.ID srv.emit("attachmentStatusChanged", { - sideEffectSource: `/${parentEntityName}(${parentKeys},IsActiveEntity=${isActiveEntity})/${compositionName}(ID=${attachmentID},IsActiveEntity=${isActiveEntity})`, + sideEffectSource: `/${parentEntityName}(${parentKeys},IsActiveEntity=${isActiveEntity})`, }) } } diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index ec78f0e1..06601a19 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -3688,6 +3688,22 @@ describe("Tests for copy() on AttachmentsService", () => { }) }) +describe("$metadata includes SideEffects for attachment compositions", () => { + it("ProcessorService $metadata contains attachmentStatusChanged SideEffects on parent entities", async () => { + const { data } = await GET( + `odata/v4/processor/$metadata?$format=json`, + ) + const jsonStr = JSON.stringify(data) + + // SideEffects annotation with our qualifier must be present on parent entity + expect(jsonStr).toContain( + "@Common.SideEffects#attachmentStatusChanged_attachments", + ) + expect(jsonStr).toContain('"SourceEvents":["attachmentStatusChanged"]') + expect(jsonStr).toContain('"TargetEntities":["attachments"]') + }) +}) + /** * Uploads attachment in draft mode using CDS test utilities * @param {Object} utils - RequestSend utility instance diff --git a/tests/unit/websocket.test.js b/tests/unit/websocket.test.js index 7c9b0b27..a40268d1 100644 --- a/tests/unit/websocket.test.js +++ b/tests/unit/websocket.test.js @@ -83,7 +83,7 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { const source = emitCall[1].sideEffectSource expect(source).toMatch( new RegExp( - `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=false\\)/attachments\\(ID=${keys.ID},IsActiveEntity=false\\)$`, + `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=false\\)$`, ), ) }) @@ -115,7 +115,7 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { const source = emitCall[1].sideEffectSource expect(source).toMatch( new RegExp( - `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=true\\)/attachments\\(ID=${keys.ID},IsActiveEntity=true\\)$`, + `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=true\\)$`, ), ) }) @@ -157,7 +157,7 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { expect(processorSrv.emit).not.toHaveBeenCalled() }) - it("constructs correct sideEffectSource path with parent entity and composition name", async () => { + it("constructs correct sideEffectSource path targeting parent entity", async () => { const target = _target() const keys = { up__ID: cds.utils.uuid(), ID: cds.utils.uuid() } @@ -178,12 +178,12 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { // Should start with /Incidents (parent entity short name) expect(source).toMatch(/^\/Incidents\(/) - // Should contain /attachments( (composition name) - expect(source).toContain("/attachments(") + // Should NOT contain attachment child path + expect(source).not.toContain("/attachments(") // Should contain parent key expect(source).toContain(`ID=${keys.up__ID}`) - // Should contain attachment ID - expect(source).toContain(`ID=${keys.ID}`) + // Should NOT contain attachment ID in path + expect(source).not.toContain(`ID=${keys.ID}`) }) it("connects to correct service derived from parent entity", async () => { @@ -235,45 +235,46 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { } }) - it("adds @Common.SideEffects annotation to attachment entities", () => { - const attachments = - cds.model.definitions["ProcessorService.Incidents.attachments"] - expect( - attachments["@Common.SideEffects#attachmentStatusChanged.SourceEvents"], - ).toEqual(["attachmentStatusChanged"]) - expect( - attachments[ - "@Common.SideEffects#attachmentStatusChanged.TargetProperties" - ], - ).toEqual(["status"]) - expect( - attachments["@Common.SideEffects#attachmentStatusChanged.TargetEntities"], - ).toEqual([{ "=": "statusNav" }]) + it("adds @Common.SideEffects annotation to parent entities targeting attachment compositions", () => { + const incidents = + cds.model.definitions["ProcessorService.Incidents"] + const sideEffects = incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] + expect(sideEffects).toBeDefined() + expect(sideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) + expect(sideEffects.TargetEntities).toEqual([{ "=": "attachments" }]) + // TargetProperties should not be set + expect(sideEffects.TargetProperties).toBeUndefined() }) - it("adds SideEffects to all attachment composition targets", () => { - const entities = [ - "ProcessorService.Incidents.attachments", - "ProcessorService.Incidents.hiddenAttachments", - "ProcessorService.Customers.attachments", - "ProcessorService.SampleRootWithComposedEntity.attachments", - "ProcessorService.Test.attachments", + it("adds SideEffects to parent entities for all attachment compositions", () => { + // Parent entities should have SideEffects with qualifier per composition + const incidents = cds.model.definitions["ProcessorService.Incidents"] + const compositionNames = [ + "attachments", + "hiddenAttachments", ] - for (const name of entities) { - const def = cds.model.definitions[name] - expect( - def["@Common.SideEffects#attachmentStatusChanged.SourceEvents"], - ).toEqual(["attachmentStatusChanged"]) + for (const compName of compositionNames) { + const qualifier = `attachmentStatusChanged_${compName}` + const sideEffects = incidents[`@Common.SideEffects#${qualifier}`] + expect(sideEffects).toBeDefined() + expect(sideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) + expect(sideEffects.TargetEntities).toEqual([{ "=": compName }]) } + + const customers = cds.model.definitions["ProcessorService.Customers"] + const custSideEffects = customers["@Common.SideEffects#attachmentStatusChanged_attachments"] + expect(custSideEffects).toBeDefined() + expect(custSideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) + expect(custSideEffects.TargetEntities).toEqual([{ "=": "attachments" }]) }) it("does not add duplicate SideEffects if already present", async () => { // The model is already loaded and enhanced; verify no duplication - const attachments = - cds.model.definitions["ProcessorService.Incidents.attachments"] - const sourceEvents = - attachments["@Common.SideEffects#attachmentStatusChanged.SourceEvents"] - expect(sourceEvents).toEqual(["attachmentStatusChanged"]) - expect(sourceEvents).toHaveLength(1) + const incidents = + cds.model.definitions["ProcessorService.Incidents"] + const sideEffects = incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] + expect(sideEffects).toBeDefined() + expect(sideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) + expect(sideEffects.SourceEvents).toHaveLength(1) }) }) From f10a02924ab28ccac3f8bea4d38919c8fb5c78f5 Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Tue, 28 Apr 2026 16:17:08 +0200 Subject: [PATCH 6/6] Formatting --- tests/integration/attachments.test.js | 4 +--- tests/unit/websocket.test.js | 28 +++++++++++---------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index 06601a19..587611a9 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -3690,9 +3690,7 @@ describe("Tests for copy() on AttachmentsService", () => { describe("$metadata includes SideEffects for attachment compositions", () => { it("ProcessorService $metadata contains attachmentStatusChanged SideEffects on parent entities", async () => { - const { data } = await GET( - `odata/v4/processor/$metadata?$format=json`, - ) + const { data } = await GET(`odata/v4/processor/$metadata?$format=json`) const jsonStr = JSON.stringify(data) // SideEffects annotation with our qualifier must be present on parent entity diff --git a/tests/unit/websocket.test.js b/tests/unit/websocket.test.js index a40268d1..2a0bb316 100644 --- a/tests/unit/websocket.test.js +++ b/tests/unit/websocket.test.js @@ -82,9 +82,7 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { const emitCall = processorSrv.emit.mock.calls[0] const source = emitCall[1].sideEffectSource expect(source).toMatch( - new RegExp( - `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=false\\)$`, - ), + new RegExp(`^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=false\\)$`), ) }) @@ -114,9 +112,7 @@ describe("updateStatus - attachmentStatusChanged event emission", () => { const emitCall = processorSrv.emit.mock.calls[0] const source = emitCall[1].sideEffectSource expect(source).toMatch( - new RegExp( - `^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=true\\)$`, - ), + new RegExp(`^/Incidents\\(ID=${keys.up__ID},IsActiveEntity=true\\)$`), ) }) @@ -236,9 +232,9 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { }) it("adds @Common.SideEffects annotation to parent entities targeting attachment compositions", () => { - const incidents = - cds.model.definitions["ProcessorService.Incidents"] - const sideEffects = incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] + const incidents = cds.model.definitions["ProcessorService.Incidents"] + const sideEffects = + incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] expect(sideEffects).toBeDefined() expect(sideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) expect(sideEffects.TargetEntities).toEqual([{ "=": "attachments" }]) @@ -249,10 +245,7 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { it("adds SideEffects to parent entities for all attachment compositions", () => { // Parent entities should have SideEffects with qualifier per composition const incidents = cds.model.definitions["ProcessorService.Incidents"] - const compositionNames = [ - "attachments", - "hiddenAttachments", - ] + const compositionNames = ["attachments", "hiddenAttachments"] for (const compName of compositionNames) { const qualifier = `attachmentStatusChanged_${compName}` const sideEffects = incidents[`@Common.SideEffects#${qualifier}`] @@ -262,7 +255,8 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { } const customers = cds.model.definitions["ProcessorService.Customers"] - const custSideEffects = customers["@Common.SideEffects#attachmentStatusChanged_attachments"] + const custSideEffects = + customers["@Common.SideEffects#attachmentStatusChanged_attachments"] expect(custSideEffects).toBeDefined() expect(custSideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) expect(custSideEffects.TargetEntities).toEqual([{ "=": "attachments" }]) @@ -270,9 +264,9 @@ describe("unfoldModel - attachmentStatusChanged event and SideEffects", () => { it("does not add duplicate SideEffects if already present", async () => { // The model is already loaded and enhanced; verify no duplication - const incidents = - cds.model.definitions["ProcessorService.Incidents"] - const sideEffects = incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] + const incidents = cds.model.definitions["ProcessorService.Incidents"] + const sideEffects = + incidents["@Common.SideEffects#attachmentStatusChanged_attachments"] expect(sideEffects).toBeDefined() expect(sideEffects.SourceEvents).toEqual(["attachmentStatusChanged"]) expect(sideEffects.SourceEvents).toHaveLength(1)