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..95a5bcf4 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,118 @@ 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 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 ( + parentDef && + comp._target._service && + !parentDef[`@Common.SideEffects#${qualifier}`] + ) { + parentDef[`@Common.SideEffects#${qualifier}`] = { + SourceEvents: ["attachmentStatusChanged"], + TargetEntities: [{ "=": comp.name }], + } } - 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", + "@ws": true, + "@ws.format": "pcp", + "@ws.pcp.sideEffect": true, + elements: { + sideEffectSource: { type: "cds.String" }, + }, + } + } + } + meta._enhanced_for_attachments = true } diff --git a/srv/attachments/basic.js b/srv/attachments/basic.js index 092f447a..a209a165 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..690a002f 100644 --- a/srv/malware-scanner/malwareScanner.js +++ b/srv/malware-scanner/malwareScanner.js @@ -199,6 +199,34 @@ class MalwareScanner extends cds.ApplicationService { UPDATE.entity(target).where(where).set(updateObject), UPDATE.entity(target.drafts).where(where).set(updateObject), ]) + const broadcastUpdate = 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 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(",") + srv.emit("attachmentStatusChanged", { + sideEffectSource: `/${parentEntityName}(${parentKeys},IsActiveEntity=${isActiveEntity})`, + }) + } + } + }) + broadcastUpdate.on("failed", (error) => { + LOG.debug( + `Emitting websocket event attachmentStatusChanged failed with: `, + error, + ) + }) } 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/integration/attachments.test.js b/tests/integration/attachments.test.js index ec78f0e1..587611a9 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -3688,6 +3688,20 @@ 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 new file mode 100644 index 00000000..2a0bb316 --- /dev/null +++ b/tests/unit/websocket.test.js @@ -0,0 +1,274 @@ +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\\)$`), + ) + }) + + 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\\)$`), + ) + }) + + 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 targeting 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, "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 NOT contain attachment child path + expect(source).not.toContain("/attachments(") + // Should contain parent key + expect(source).toContain(`ID=${keys.up__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 () => { + 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 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 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 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 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) + }) +})