diff --git a/app/single-tenant/personal-space/incidents-app/srv/service.cds b/app/single-tenant/personal-space/incidents-app/srv/service.cds index 8bf831d3..63cc2b0b 100644 --- a/app/single-tenant/personal-space/incidents-app/srv/service.cds +++ b/app/single-tenant/personal-space/incidents-app/srv/service.cds @@ -61,6 +61,8 @@ extend my.Incidents with { } extend my.Projects with { references: Composition of many Attachments } +annotate my.Incidents.references with @SDM.useClientCredential: false; + extend Attachments with { customProperty1 : Association to WDIRSCodeList @SDM.Attachments.AdditionalProperty: { diff --git a/lib/sdm.js b/lib/sdm.js index 6dac62b0..bf50faa6 100644 --- a/lib/sdm.js +++ b/lib/sdm.js @@ -24,8 +24,10 @@ const { getSecondaryTypeProperties, getUpdatedSecondaryProperties, getSdmInstanceName, + getSdmClientId, transformSDMServiceBindingToJWTBearerCredentialsDestination, - transformSDMServiceBindingToClientCredentialsDestination + transformSDMServiceBindingToClientCredentialsDestination, + isClientCredentialForced } = require("./util/index"); const { getDraftAttachments, @@ -78,11 +80,20 @@ const { const { getDestinationFromServiceBinding,retrieveJwt} = require('@sap-cloud-sdk/connectivity'); const { executeHttpRequest } = require('@sap-cloud-sdk/http-client'); +// Resolve the base class across @cap-js/attachments layouts: 3.8 ships it at +// "srv/basic", 3.12+ ships it at "srv/attachments/basic". Try both so the +// plugin works regardless of which version actually got installed. +function resolveAttachmentsBasic() { + try { + return require("@cap-js/attachments/srv/attachments/basic"); + } catch { + return require("@cap-js/attachments/srv/basic"); + } +} + const FILE_SIZE_THRESHOLD = 400 * 1024 * 1024; // 400 MB — files above this use the chunked upload path -module.exports = class SDMAttachmentsService extends ( - require("@cap-js/attachments/srv/attachments/basic") -) { +module.exports = class SDMAttachmentsService extends resolveAttachmentsBasic() { async init() { this.creds = this.options.credentials; this.originalUrlMap = new Map(); @@ -97,15 +108,18 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; }); return technicalUserDestn; } - async getDestination(req) { - // Cache destination on request object to avoid multiple calls - if (req._sdmDestination) { - return req._sdmDestination; + async getDestination(req, attachmentsEntity) { + // Cache destinations per-composition on the request: a parent with two + // attachment compositions (one annotated, one not) needs both flavors, + // and the non-rename callsites still hit '_default' for back-compat. + const cacheKey = attachmentsEntity?.name || '_default'; + if (req._sdmDestinations?.[cacheKey]) { + return req._sdmDestinations[cacheKey]; } const userJwt = retrieveJwt(req); let destination; - if (!cds.context?.user?.authInfo?.token?.payload?.origin) { + if (isClientCredentialForced(req, attachmentsEntity) || !cds.context?.user?.authInfo?.token?.payload?.origin) { let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; destination = await getDestinationFromServiceBinding({ destinationName: getSdmInstanceName(), @@ -121,7 +135,8 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; // Cache for subsequent calls in the same request - req._sdmDestination = destination; + if (!req._sdmDestinations) req._sdmDestinations = {}; + req._sdmDestinations[cacheKey] = destination; return destination; } getSDMCredentials() { @@ -155,7 +170,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; if (!req) { throw new Error('HTTP request context not available'); } - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachments); const content = await readAttachment(Key, destination, this.creds); if(content && typeof content === 'object' && content.status && content.status == 200){ return content.data; @@ -250,7 +265,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; } async updateDraftAttachments(req, attachment, attachmentsEntity, secondaryPropertiesWithInvalidDefinitions, secondaryTypeProperties, compositionName) { - const attachmentData = await this.getAttachementDataInSDM(this.creds.uri, attachment.url, req); + const attachmentData = await this.getAttachementDataInSDM(this.creds.uri, attachment.url, req, attachmentsEntity); const filenameInSDM = attachmentData.filename; const context = { @@ -312,7 +327,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; if (Object.keys(updatedSecondaryProperties).length > 0) { const updateResult = await this.performAttachmentUpdate( - req, attachment, updatedSecondaryProperties, invalidDefinitions, filenameInRequest + req, attachment, updatedSecondaryProperties, invalidDefinitions, filenameInRequest, attachmentsEntity ); if (updateResult.error) { @@ -334,9 +349,9 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; return null; } - async performAttachmentUpdate(req, attachment, updatedSecondaryProperties, invalidDefinitions, filenameInRequest) { + async performAttachmentUpdate(req, attachment, updatedSecondaryProperties, invalidDefinitions, filenameInRequest, attachmentsEntity) { try { - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachmentsEntity); const responseCode = await updateAttachment(req, attachment, this.creds, destination, updatedSecondaryProperties, invalidDefinitions); switch (responseCode) { @@ -495,8 +510,8 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; return errorResponse; } - async getAttachementDataInSDM(uri, objectId, req) { - const destination = await this.getDestination(req); + async getAttachementDataInSDM(uri, objectId, req, attachmentsEntity) { + const destination = await this.getDestination(req, attachmentsEntity); const response = await getAttachment(uri, destination, objectId); const responseData = { filename: response?.data?.succinctProperties["cmis:name"], folderId: response?.data?.succinctProperties["sap:parentIds"][0] }; return responseData; @@ -712,7 +727,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; } } if (!parentId) { - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachments); const folderId = await getFolderIdByPath( req, this.creds, @@ -1310,6 +1325,16 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; linkUrl: req.data?.url, DraftAdministrativeData_DraftUUID: draftUUID[0].DraftAdministrativeData_DraftUUID, }; + + // Custom action bypasses srv.before hooks, so stamp the SDM technical + // user directly into the draft row when the annotation is in effect. + // Without this, CAP's managed aspect would persist req.user.id and the + // UI would show the human name even though DI shows the client_id. + if (isClientCredentialForced(req)) { + const clientId = getSdmClientId(); + if (clientId) this._setManagedUser(updatedFields, 'CREATE', clientId); + } + console.info(`[createLink] updating link in draft`, { updatedFields }); await updateLinkInDraft(req, updatedFields); @@ -1342,7 +1367,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; const newLinkUrl = req.data.url; const filenameToUpdate = existingAttachment.filename.replace(/\.url$/, ''); const objectIdToUpdate = existingAttachment.url; - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachmentsEntity); const response = await editLink(objectIdToUpdate, filenameToUpdate, newLinkUrl, this.creds, destination); const status = response?.status || response?.code; @@ -1360,6 +1385,14 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; linkUrl: newLinkUrl, note: `__BASELINE_URL__:${baselineUrl}` }; + + // Custom action bypasses srv.before hooks, so stamp the SDM technical + // user directly. UPDATE event => modifiedBy only. + if (isClientCredentialForced(req)) { + const clientId = getSdmClientId(); + if (clientId) this._setManagedUser(updatedFields, 'UPDATE', clientId); + } + await editLinkInDraft(req, updatedFields); return { success: true, @@ -1378,6 +1411,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; const baseEntityName = req.target.name; const attachmentCompositions = this.getAttachmentCompositions({ name: baseEntityName }); const parentId = req.data?.ID; + const clientId = getSdmClientId(); for (const compositionName of attachmentCompositions) { const attachmentsEntityName = `${baseEntityName}.${compositionName}`; @@ -1397,10 +1431,96 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; }); } + // After draft activation, fix up createdBy/modifiedBy on the active + // table: + // - Freshly activated rows (new uploads / new links): set BOTH + // createdBy and modifiedBy to client_id. createdBy is set here once + // and never touched again. + // - Pre-existing rows whose draft entry was edited under client + // credentials in this session: set ONLY modifiedBy to client_id. + // createdBy is intentionally left as-is. + // Untouched rows (in draft only because the draft was opened, not + // edited) are filtered out by the draft.modifiedBy === client_id check + // captured in the snapshot. + if ( + clientId && + parentId && + attachmentsEntity["@SDM.useClientCredential"] === true && + req._sdmSaveSnapshot?.[compositionName] + ) { + const { preExistingActiveIds, draftClientStampedIds } = + req._sdmSaveSnapshot[compositionName]; + const draftIds = [...draftClientStampedIds]; + const freshlyActivated = draftIds.filter((id) => !preExistingActiveIds.has(id)); + const modifiedExisting = draftIds.filter((id) => preExistingActiveIds.has(id)); + + if (freshlyActivated.length > 0) { + await global.UPDATE(attachmentsEntityName) + .set({ createdBy: clientId, modifiedBy: clientId }) + .where({ ID: { in: freshlyActivated } }); + } + if (modifiedExisting.length > 0) { + await global.UPDATE(attachmentsEntityName) + .set({ modifiedBy: clientId }) + .where({ ID: { in: modifiedExisting } }); + } + } + await this.updateBaselinesForEntity(attachmentsEntityName); } } + /** + * Snapshot two ID sets per annotated attachment composition before draft + * activation runs: + * 1. preExistingActiveIds — rows already on the active table for this + * parent. Used to distinguish freshly activated rows from edits to + * pre-existing rows. + * 2. draftClientStampedIds — rows in the draft table whose modifiedBy is + * client_id, i.e. the user actually touched them in this draft session + * under @SDM.useClientCredential. Untouched draft rows (the draft was + * opened but the row wasn't edited) are excluded so we don't bump + * modifiedBy on rows the user only looked at. + * Stored on req._sdmSaveSnapshot[compositionName]. Only populated for + * compositions whose target carries @SDM.useClientCredential: true. + */ + async captureSaveSnapshot(req) { + const clientId = getSdmClientId(); + if (!clientId) return; + const baseEntityName = req.target.name; + const parentId = req.data?.ID; + if (!parentId) return; + + const attachmentCompositions = this.getAttachmentCompositions({ name: baseEntityName }); + if (attachmentCompositions.length === 0) return; + + req._sdmSaveSnapshot = {}; + for (const compositionName of attachmentCompositions) { + const attachmentsEntityName = `${baseEntityName}.${compositionName}`; + const attachmentsEntity = cds.model.definitions[attachmentsEntityName]; + if (!attachmentsEntity) continue; + if (attachmentsEntity["@SDM.useClientCredential"] !== true) continue; + + const upKey = attachmentsEntity.keys?.up_?.keys?.[0]?.$generatedFieldName || 'up__ID'; + const draftEntityName = `${attachmentsEntityName}.drafts`; + + const activeRows = await global.SELECT.from(attachmentsEntityName) + .columns('ID') + .where({ [upKey]: parentId }); + + const draftRows = cds.model.definitions[draftEntityName] + ? await global.SELECT.from(draftEntityName) + .columns('ID') + .where({ [upKey]: parentId, modifiedBy: clientId }) + : []; + + req._sdmSaveSnapshot[compositionName] = { + preExistingActiveIds: new Set(activeRows.map((r) => r.ID)), + draftClientStampedIds: new Set(draftRows.map((r) => r.ID)) + }; + } + } + async updateBaselinesForEntity(attachmentsEntityName) { for (const [attachmentKey] of this.originalUrlMap.entries()) { const attachment = await global.SELECT.one.from(attachmentsEntityName) @@ -1451,7 +1571,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; const baselineUrl = attachment.note.substring("__BASELINE_URL__:".length); if (baselineUrl && attachment.linkUrl !== baselineUrl) { - await this.revertLinkInSDM(attachment, baselineUrl, req); + await this.revertLinkInSDM(attachment, baselineUrl, req, attachmentsEntity); const attachmentKey = `${attachment.ID}`; this.originalUrlMap.delete(attachmentKey); } @@ -1460,11 +1580,11 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; } } - async revertLinkInSDM(draftAttachment, originalLinkUrl, req) { + async revertLinkInSDM(draftAttachment, originalLinkUrl, req, attachmentsEntity) { try { const filenameToUpdate = draftAttachment.filename.replace(/\.url$/, ''); const objectIdToUpdate = draftAttachment.url; - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachmentsEntity); await editLink( objectIdToUpdate, @@ -1799,7 +1919,8 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; attachment, updatedSecondaryProperties, validationContext.invalidDefinitions, - newFilename + newFilename, + attachmentsEntity ); } @@ -1828,9 +1949,9 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; * Updates attachment in SDM and maps response to error object * @private */ - async _updateAttachmentInSDM(req, currentAttachment, attachment, updatedProperties, invalidDefinitions, filename) { + async _updateAttachmentInSDM(req, currentAttachment, attachment, updatedProperties, invalidDefinitions, filename, attachmentsEntity) { try { - const destination = await this.getDestination(req); + const destination = await this.getDestination(req, attachmentsEntity); const responseCode = await updateAttachment( req, { ...currentAttachment, ...attachment }, @@ -1903,6 +2024,54 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; } } + /** + * When @SDM.useClientCredential is set on the attachment target, the file is + * uploaded to SDM under the technical user (UAA clientid). To keep the plugin + * DB consistent with what DMS/DI shows, we override createdBy/modifiedBy on + * the request data so CAP's managed aspect doesn't fall back to the human + * user. Runs as a before-hook for CREATE/UPDATE/PUT on the attachment entity + * (and its drafts), and for SAVE on the parent entity (where we have to walk + * the attachment compositions, since the annotation lives on the target). + */ + applyClientCredentialUser(req) { + const clientId = getSdmClientId(); + if (!clientId) return; + + // Direct attachment-target operations: req.target carries the annotation. + if (isClientCredentialForced(req)) { + this._setManagedUser(req.data, req.event, clientId); + return; + } + + // Parent SAVE: annotation lives on the composition target, not on req.target. + if (req.event !== 'SAVE' && req.event !== 'CREATE' && req.event !== 'UPDATE') return; + if (!req.target?.elements) return; + + for (const [elementName, element] of Object.entries(req.target.elements)) { + if (element.type !== 'cds.Composition' || !element.target) continue; + const targetDef = cds.model.definitions[element.target]; + if (!targetDef?.["@SDM.useClientCredential"]) continue; + const rows = req.data?.[elementName]; + if (!Array.isArray(rows)) continue; + for (const row of rows) { + // Skip rows the user did not touch: + // - 'delete' rows shouldn't have managed fields written + // - 'read' rows are included by CAP for reference / unchanged children; + // stamping modifiedBy here would falsely mark them as edited + if (row?._op === 'delete' || row?._op === 'read') continue; + this._setManagedUser(row, row?._op === 'update' ? 'UPDATE' : 'CREATE', clientId); + } + } + } + + _setManagedUser(data, event, clientId) { + if (!data || typeof data !== 'object') return; + if (event === 'CREATE' || event === 'PUT') { + data.createdBy = clientId; + } + data.modifiedBy = clientId; + } + registerHandlers(srv) { // First call the parent registerHandlers to ensure base functionality is registered if (super.registerHandlers) { @@ -1930,6 +2099,23 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; registerSDMHandlers(srv, entity, target) { + // When @SDM.useClientCredential is set, override createdBy/modifiedBy with + // the SDM technical user clientid so the plugin DB matches DMS/DI. Run + // these first so they win against later managed-aspect defaults. + const applyClientCred = this.applyClientCredentialUser.bind(this); + srv.before(["CREATE", "UPDATE", "PUT"], target, applyClientCred); + if (target.drafts) { + srv.before(["CREATE", "UPDATE", "PUT"], target.drafts, applyClientCred); + } + if (entity.drafts) { + // Draft activation: annotation lives on the composition target, so we + // hook on the parent SAVE and walk compositions inside the handler. + srv.before("SAVE", entity, applyClientCred); + } else { + // Non-draft parent UPDATE/CREATE may carry composition rows too. + srv.before(["CREATE", "UPDATE"], entity, applyClientCred); + } + const entityKey = entity.name; const targetKey = target.name; @@ -1956,6 +2142,11 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; // Draft-specific handlers if (entity.drafts) { srv.before("DELETE", entity.drafts, this.handleDraftDiscardForLinks.bind(this)); + // Snapshot existing attachment IDs BEFORE activation so the after-SAVE + // stamp can target only freshly activated rows. Registered before the + // rename handler so req._sdmSaveSnapshot is populated for everything + // downstream that runs in the SAVE flow. + srv.before("SAVE", entity, this.captureSaveSnapshot.bind(this)); srv.after("SAVE", entity, this.handleDraftSaveForLinks.bind(this)); srv.before("SAVE", entity, this.draftEntityRenameHandler.bind(this)); } else { diff --git a/lib/util/index.js b/lib/util/index.js index 8ca1a603..74479835 100644 --- a/lib/util/index.js +++ b/lib/util/index.js @@ -1,6 +1,6 @@ const cds = require("@sap/cds"); const NodeCache = require("node-cache"); -const { sdmAnnotationAdditionalpropertyName, sdmAnnotationAdditionalproperty } = require("./messageConsts"); +const { sdmAnnotationAdditionalpropertyName, sdmAnnotationAdditionalproperty, sdmAnnotationUseClientCredential } = require("./messageConsts"); const cache = new NodeCache(); const { jwtBearerToken, serviceToken, decodeJwt } = require('@sap-cloud-sdk/connectivity'); @@ -324,14 +324,66 @@ function getContentLength(content) { return -1; } +// Cache for parsed VCAP_SERVICES. VCAP_SERVICES is set once at process start and +// never changes at runtime, but the two getters below are called multiple times +// per request — without caching, each call re-runs JSON.parse on a multi-KB +// blob. We key the cache on the raw env-var string so any (test-time) reassignment +// invalidates it automatically. +let _vcapCache = { raw: undefined, parsed: undefined }; +function _getVcapServices() { + const data = process.env.VCAP_SERVICES; + if (_vcapCache.raw === data) return _vcapCache.parsed; + let parsed = null; + if (data) { + try { + parsed = JSON.parse(data); + } catch { + parsed = null; + } + } + _vcapCache = { raw: data, parsed }; + return parsed; +} + function getSdmInstanceName() { - let data = process.env.VCAP_SERVICES; - let sdmInstanceName = null; - let jsonData = JSON.parse(data); - if (jsonData.sdm && jsonData.sdm.length > 0) { - sdmInstanceName = jsonData.sdm[0].name; + const jsonData = _getVcapServices(); + if (jsonData?.sdm && jsonData.sdm.length > 0) { + return jsonData.sdm[0].name; + } + return null; +} + +// Returns the SDM service binding's UAA clientid — i.e. the technical user that +// DMS/DI sees when @SDM.useClientCredential is in effect. Used to keep the +// plugin DB's createdBy/modifiedBy aligned with the actual SDM principal. +function getSdmClientId() { + const jsonData = _getVcapServices(); + if (jsonData?.sdm && jsonData.sdm.length > 0) { + return jsonData.sdm[0].credentials?.uaa?.clientid || null; + } + return null; +} + +function isClientCredentialForced(req, attachmentsEntity) { + // Explicit entity wins — caller knows which composition it's working with, + // so per-composition selection is honored even when the parent has multiple + // attachment compositions with different annotations. + if (attachmentsEntity) + return attachmentsEntity[sdmAnnotationUseClientCredential] === true; + // Direct attachment-target call (e.g. CREATE on Incidents.references). + if (req.target?.[sdmAnnotationUseClientCredential] === true) return true; + // Parent-entity fallback (rename / SAVE handlers that haven't been threaded + // with the explicit entity): scan attachment compositions on req.target. + const elements = req.target?.elements; + if (elements) { + for (const element of Object.values(elements)) { + if (element?.type === 'cds.Composition' && element.target) { + const targetDef = cds.model.definitions[element.target]; + if (targetDef?.[sdmAnnotationUseClientCredential] === true) return true; + } + } } - return sdmInstanceName; + return false; } module.exports = { @@ -351,5 +403,7 @@ module.exports = { transformSDMServiceBindingToJWTBearerCredentialsDestination, transformSDMServiceBindingToClientCredentialsDestination, buildClientCredentialsDestination, - getSdmInstanceName + getSdmInstanceName, + getSdmClientId, + isClientCredentialForced }; diff --git a/lib/util/messageConsts.js b/lib/util/messageConsts.js index 4707fc19..12d6e346 100644 --- a/lib/util/messageConsts.js +++ b/lib/util/messageConsts.js @@ -60,6 +60,7 @@ module.exports.linkNameConstraintMessage = (fileNameWithRestrictedCharacters, op }; module.exports.sdmAnnotationAdditionalpropertyName = "@SDM.Attachments.AdditionalProperty.name"; module.exports.sdmAnnotationAdditionalproperty = "@SDM.Attachments.AdditionalProperty"; +module.exports.sdmAnnotationUseClientCredential = "@SDM.useClientCredential"; module.exports.updateAttachmentError = "Could not update the attachment"; module.exports.sdmRolesErrorMessage = "Unable to rename the file due to an error at the server"; module.exports.unsupportedProperties = "Unsupported properties"; diff --git a/test/lib/sdm.test.js b/test/lib/sdm.test.js index 26ee57fc..04915c03 100644 --- a/test/lib/sdm.test.js +++ b/test/lib/sdm.test.js @@ -6,6 +6,8 @@ const { getConfigurations, isRepositoryVersioned, getSdmInstanceName, + getSdmClientId, + isClientCredentialForced, isRestrictedCharactersInName, getStatusCondition, getPropertyTitles, @@ -70,7 +72,12 @@ let { mimeTypeInvalidError } = require("../../lib/util/messageConsts"); -jest.mock("@cap-js/attachments/srv/attachments/basic", () => class { +// @cap-js/attachments switched the base-class path between versions: +// 3.8 ships it at "srv/basic", 3.12+ at "srv/attachments/basic". virtual:true +// lets jest.mock register a name that doesn't physically exist in the +// installed copy, so whichever layout sdm.js resolves at runtime, our mock +// wins. +const _attachmentsBasicMock = class { async init() { return Promise.resolve(); } @@ -85,7 +92,9 @@ jest.mock("@cap-js/attachments/srv/attachments/basic", () => class { registerHandlers(_srv) { // Mock parent registerHandlers } -}); +}; +jest.mock("@cap-js/attachments/srv/basic", () => _attachmentsBasicMock, { virtual: true }); +jest.mock("@cap-js/attachments/srv/attachments/basic", () => _attachmentsBasicMock, { virtual: true }); jest.mock("@sap-cloud-sdk/connectivity", () => ({ getDestinationFromServiceBinding: jest.fn(), retrieveJwt: jest.fn() @@ -119,8 +128,10 @@ jest.mock("../../lib/util", () => ({ getConfigurations: jest.fn(), isRepositoryVersioned: jest.fn(), getSdmInstanceName: jest.fn(), + getSdmClientId: jest.fn(), transformSDMServiceBindingToJWTBearerCredentialsDestination: jest.fn(), transformSDMServiceBindingToClientCredentialsDestination: jest.fn(), + isClientCredentialForced: jest.fn(), isRestrictedCharactersInName: jest.fn(), getStatusCondition: jest.fn(), getPropertyTitles: jest.fn(), @@ -477,9 +488,7 @@ describe("SDMAttachmentsService", () => { describe("getDestination", () => { it("should get destination and cache it on request object", async () => { const service = new SDMAttachmentsService(); - const mockReq = { - _sdmDestination: undefined - }; + const mockReq = {}; cds.context = { user: { authInfo: { @@ -509,7 +518,7 @@ describe("SDMAttachmentsService", () => { serviceBindingTransformFn: expect.any(Function) }); expect(result).toEqual(mockDestination); - expect(mockReq._sdmDestination).toEqual(mockDestination); + expect(mockReq._sdmDestinations._default).toEqual(mockDestination); }); it("should return cached destination when available", async () => { @@ -517,7 +526,7 @@ describe("SDMAttachmentsService", () => { const service = new SDMAttachmentsService(); const cachedDestination = { url: "http://cached.com" }; const mockReq = { - _sdmDestination: cachedDestination + _sdmDestinations: { _default: cachedDestination } }; const result = await service.getDestination(mockReq); @@ -526,6 +535,54 @@ describe("SDMAttachmentsService", () => { expect(result).toEqual(cachedDestination); }); + it("should cache per-attachment-entity to support parents with multiple compositions", async () => { + jest.clearAllMocks(); + const service = new SDMAttachmentsService(); + const mockReq = {}; + cds.context = { + user: { + authInfo: { token: { payload: { origin: "sap.custom", ext_attr: { zdn: "sub" } } } } + } + }; + retrieveJwt.mockReturnValue("user-jwt"); + getSdmInstanceName.mockReturnValue("sdm-instance"); + const dest1 = { url: "http://a.com" }; + const dest2 = { url: "http://b.com" }; + getDestinationFromServiceBinding + .mockResolvedValueOnce(dest1) + .mockResolvedValueOnce(dest2); + + const annotated = { name: 'Refs', '@SDM.useClientCredential': true }; + const unannotated = { name: 'Plain' }; + + // The same request needs both flavors; per-entity cache makes this safe. + isClientCredentialForced + .mockReturnValueOnce(true) // for annotated entity + .mockReturnValueOnce(false); // for unannotated entity + + const result1 = await service.getDestination(mockReq, annotated); + const result2 = await service.getDestination(mockReq, unannotated); + + expect(result1).toEqual(dest1); + expect(result2).toEqual(dest2); + expect(mockReq._sdmDestinations.Refs).toEqual(dest1); + expect(mockReq._sdmDestinations.Plain).toEqual(dest2); + expect(getDestinationFromServiceBinding).toHaveBeenCalledTimes(2); + }); + + it("should reuse cached per-entity destination across calls", async () => { + jest.clearAllMocks(); + const service = new SDMAttachmentsService(); + const cached = { url: "http://cached.com" }; + const mockReq = { _sdmDestinations: { Refs: cached } }; + const entity = { name: 'Refs' }; + + const result = await service.getDestination(mockReq, entity); + + expect(getDestinationFromServiceBinding).not.toHaveBeenCalled(); + expect(result).toEqual(cached); + }); + it("should execute client-credentials transform callback when origin is missing", async () => { const service = new SDMAttachmentsService(); const mockReq = { _sdmDestination: undefined }; @@ -600,9 +657,7 @@ describe("SDMAttachmentsService", () => { it("should use Client Credentials when origin is not present in token", async () => { jest.clearAllMocks(); const service = new SDMAttachmentsService(); - const mockReq = { - _sdmDestination: undefined - }; + const mockReq = {}; // Mock cds.context without origin in token payload cds.context = { @@ -634,15 +689,13 @@ describe("SDMAttachmentsService", () => { serviceBindingTransformFn: expect.any(Function) }); expect(result).toEqual(mockDestination); - expect(mockReq._sdmDestination).toEqual(mockDestination); + expect(mockReq._sdmDestinations._default).toEqual(mockDestination); }); it("should use JWT Bearer when origin is present in token", async () => { jest.clearAllMocks(); const service = new SDMAttachmentsService(); - const mockReq = { - _sdmDestination: undefined - }; + const mockReq = {}; // Mock cds.context with origin in token payload cds.context = { @@ -675,7 +728,7 @@ describe("SDMAttachmentsService", () => { serviceBindingTransformFn: expect.any(Function) }); expect(result).toEqual(mockDestination); - expect(mockReq._sdmDestination).toEqual(mockDestination); + expect(mockReq._sdmDestinations._default).toEqual(mockDestination); }); describe("getSDMCredentials", () => { it("should return credentials", () => { @@ -3957,7 +4010,7 @@ describe("SDMAttachmentsService", () => { } } }); - + await service.createLink(linkToCreateInSDM, credentials, req, parentId, upIdKey); expect(createAttachment).toHaveBeenCalledWith( @@ -3985,6 +4038,44 @@ describe("SDMAttachmentsService", () => { ); }); + it("should stamp createdBy and modifiedBy with SDM client_id when @SDM.useClientCredential is set", async () => { + // Covers the `isClientCredentialForced(req)` true-branch in createLink (PR #279 review fix #3 path) + setupDestinationMocks(); + isClientCredentialForced.mockReturnValue(true); + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + createAttachment.mockResolvedValueOnce({ + status: 201, + data: { succinctProperties: { "cmis:objectId": "objId" } } + }); + + await service.createLink(linkToCreateInSDM, credentials, req, parentId, upIdKey); + + expect(updateLinkInDraft).toHaveBeenCalledWith( + req, + expect.objectContaining({ + createdBy: 'sb-clientid-xyz', + modifiedBy: 'sb-clientid-xyz' + }) + ); + }); + + it("does NOT stamp createdBy/modifiedBy when client_id resolution returns null", async () => { + // Covers the `if (clientId)` false-branch: annotation set but VCAP misconfigured + setupDestinationMocks(); + isClientCredentialForced.mockReturnValue(true); + getSdmClientId.mockReturnValue(null); + createAttachment.mockResolvedValueOnce({ + status: 201, + data: { succinctProperties: { "cmis:objectId": "objId" } } + }); + + await service.createLink(linkToCreateInSDM, credentials, req, parentId, upIdKey); + + const fields = updateLinkInDraft.mock.calls[0][1]; + expect(fields.createdBy).toBeUndefined(); + expect(fields.modifiedBy).toBeUndefined(); + }); + it("should reject with duplicateFileErr if nameConstraintViolation", async () => { createAttachment.mockResolvedValueOnce({ status: 400, @@ -4213,7 +4304,201 @@ describe("SDMAttachmentsService", () => { expect(service.updateBaselinesForEntity).not.toHaveBeenCalled(); }); }); - + + describe('captureSaveSnapshot', () => { + const { getSdmClientId } = require("../../lib/util"); + let service; + let req; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SDMAttachmentsService(); + // Wire SELECT chain to chainable from -> columns -> where + global.SELECT.from = jest.fn().mockReturnThis(); + global.SELECT.columns = jest.fn().mockReturnThis(); + global.SELECT.where = jest.fn(); + req = { + target: { name: 'ProcessorService.Incidents' }, + data: { ID: 'parent-1' } + }; + }); + + it('returns early when no clientId', async () => { + getSdmClientId.mockReturnValue(null); + await service.captureSaveSnapshot(req); + expect(req._sdmSaveSnapshot).toBeUndefined(); + }); + + it('returns early when no parentId', async () => { + getSdmClientId.mockReturnValue('client-X'); + req.data = {}; + await service.captureSaveSnapshot(req); + expect(req._sdmSaveSnapshot).toBeUndefined(); + }); + + it('skips compositions without the annotation', async () => { + getSdmClientId.mockReturnValue('client-X'); + cds.model.definitions['ProcessorService.Incidents'] = { + elements: { + refs: { type: 'cds.Composition', target: 'ProcessorService.Incidents.refs' } + } + }; + cds.model.definitions['ProcessorService.Incidents.refs'] = { + includes: ['sap.attachments.Attachments'], + keys: { up_: { keys: [{ $generatedFieldName: 'up__ID' }] } } + // no @SDM.useClientCredential + }; + await service.captureSaveSnapshot(req); + expect(req._sdmSaveSnapshot).toEqual({}); + }); + + it('captures preExistingActiveIds and draftClientStampedIds when annotated', async () => { + getSdmClientId.mockReturnValue('client-X'); + cds.model.definitions['ProcessorService.Incidents'] = { + elements: { + references: { type: 'cds.Composition', target: 'ProcessorService.Incidents.references' } + } + }; + cds.model.definitions['ProcessorService.Incidents.references'] = { + includes: ['sap.attachments.Attachments'], + keys: { up_: { keys: [{ $generatedFieldName: 'up__ID' }] } }, + '@SDM.useClientCredential': true + }; + cds.model.definitions['ProcessorService.Incidents.references.drafts'] = { name: 'drafts' }; + + // Two SELECT calls: active first, draft second + global.SELECT.where + .mockResolvedValueOnce([{ ID: 'a1' }, { ID: 'a2' }]) + .mockResolvedValueOnce([{ ID: 'a2' }]); + + await service.captureSaveSnapshot(req); + + const snap = req._sdmSaveSnapshot.references; + expect(snap.preExistingActiveIds).toEqual(new Set(['a1', 'a2'])); + expect(snap.draftClientStampedIds).toEqual(new Set(['a2'])); + }); + }); + + describe('handleDraftSaveForLinks - client credential stamping', () => { + const { getSdmClientId } = require("../../lib/util"); + let service; + let req; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SDMAttachmentsService(); + service.originalUrlMap = new Map(); + service.updateBaselinesForEntity = jest.fn(); + global.UPDATE.mockClear().mockImplementation(() => ({ + set: jest.fn().mockReturnThis(), + where: jest.fn().mockResolvedValue() + })); + req = { + target: { name: 'ProcessorService.Incidents' }, + data: { ID: 'parent-1' } + }; + cds.model.definitions['ProcessorService.Incidents'] = { + elements: { + references: { type: 'cds.Composition', target: 'ProcessorService.Incidents.references' } + } + }; + cds.model.definitions['ProcessorService.Incidents.references'] = { + includes: ['sap.attachments.Attachments'], + keys: { up_: { keys: [{ $generatedFieldName: 'up__ID' }] } }, + '@SDM.useClientCredential': true + }; + }); + + it('stamps both createdBy and modifiedBy on freshly activated rows; only modifiedBy on edited existing', async () => { + getSdmClientId.mockReturnValue('client-X'); + req._sdmSaveSnapshot = { + references: { + preExistingActiveIds: new Set(['old-1']), + draftClientStampedIds: new Set(['old-1', 'new-1']) + } + }; + + const updateCalls = []; + global.UPDATE.mockImplementation((entityName) => { + const obj = { + set: jest.fn(function (data) { updateCalls.push({ entityName, data, where: null }); return obj; }), + where: jest.fn(function (where) { updateCalls[updateCalls.length - 1].where = where; return Promise.resolve(); }) + }; + return obj; + }); + + await service.handleDraftSaveForLinks({}, req); + + // Filter out the mimeType fix; pick the createdBy/modifiedBy stamping calls + const stampingCalls = updateCalls.filter((c) => c.data.createdBy || c.data.modifiedBy); + expect(stampingCalls).toHaveLength(2); + + const fresh = stampingCalls.find((c) => c.data.createdBy); + expect(fresh.data).toEqual({ createdBy: 'client-X', modifiedBy: 'client-X' }); + expect(fresh.where).toEqual({ ID: { in: ['new-1'] } }); + + const modified = stampingCalls.find((c) => !c.data.createdBy); + expect(modified.data).toEqual({ modifiedBy: 'client-X' }); + expect(modified.where).toEqual({ ID: { in: ['old-1'] } }); + }); + + it('does nothing when annotation absent', async () => { + getSdmClientId.mockReturnValue('client-X'); + cds.model.definitions['ProcessorService.Incidents.references']['@SDM.useClientCredential'] = false; + req._sdmSaveSnapshot = { + references: { preExistingActiveIds: new Set(), draftClientStampedIds: new Set(['x']) } + }; + + const updateCalls = []; + global.UPDATE.mockImplementation((entityName) => { + const obj = { + set: jest.fn(function (data) { updateCalls.push({ entityName, data }); return obj; }), + where: jest.fn().mockResolvedValue() + }; + return obj; + }); + + await service.handleDraftSaveForLinks({}, req); + expect(updateCalls.filter((c) => c.data.createdBy || c.data.modifiedBy)).toHaveLength(0); + }); + + it('does nothing when no clientId', async () => { + getSdmClientId.mockReturnValue(null); + req._sdmSaveSnapshot = { + references: { preExistingActiveIds: new Set(), draftClientStampedIds: new Set(['x']) } + }; + + const updateCalls = []; + global.UPDATE.mockImplementation((entityName) => { + const obj = { + set: jest.fn(function (data) { updateCalls.push({ entityName, data }); return obj; }), + where: jest.fn().mockResolvedValue() + }; + return obj; + }); + + await service.handleDraftSaveForLinks({}, req); + expect(updateCalls.filter((c) => c.data.createdBy || c.data.modifiedBy)).toHaveLength(0); + }); + + it('does nothing when snapshot missing for composition', async () => { + getSdmClientId.mockReturnValue('client-X'); + req._sdmSaveSnapshot = {}; + + const updateCalls = []; + global.UPDATE.mockImplementation((entityName) => { + const obj = { + set: jest.fn(function (data) { updateCalls.push({ entityName, data }); return obj; }), + where: jest.fn().mockResolvedValue() + }; + return obj; + }); + + await service.handleDraftSaveForLinks({}, req); + expect(updateCalls.filter((c) => c.data.createdBy || c.data.modifiedBy)).toHaveLength(0); + }); + }); + describe('updateBaselinesForEntity', () => { let service; @@ -4342,7 +4627,8 @@ describe("SDMAttachmentsService", () => { expect(service.revertLinkInSDM).toHaveBeenCalledWith( draftAttachments[0], 'http://original.com', - req + req, + expect.any(Object) ); expect(service.originalUrlMap.has('attach1')).toBe(false); }); @@ -7611,6 +7897,249 @@ describe("SDMAttachmentsService", () => { }); }); + describe('getDestination — per-composition cache', () => { + it('uses attachmentsEntity.name as cache key when provided', async () => { + const service = new SDMAttachmentsService(); + const mockDest = { url: 'http://cached-with-key/' }; + const req = { _sdmDestinations: { 'MyEntity.references': mockDest } }; + const attachmentsEntity = { name: 'MyEntity.references' }; + + const result = await service.getDestination(req, attachmentsEntity); + expect(result).toBe(mockDest); + }); + }); + + describe('_setManagedUser — branch coverage', () => { + let service; + beforeEach(() => { + service = new SDMAttachmentsService(); + }); + + it('returns silently when data is null', () => { + expect(() => service._setManagedUser(null, 'CREATE', 'client-id')).not.toThrow(); + }); + + it('returns silently when data is not an object (string)', () => { + expect(() => service._setManagedUser('not-an-object', 'CREATE', 'client-id')).not.toThrow(); + }); + + it('sets createdBy and modifiedBy on CREATE event', () => { + const data = {}; + service._setManagedUser(data, 'CREATE', 'client-id'); + expect(data.createdBy).toBe('client-id'); + expect(data.modifiedBy).toBe('client-id'); + }); + + it('sets createdBy and modifiedBy on PUT event', () => { + const data = {}; + service._setManagedUser(data, 'PUT', 'client-id'); + expect(data.createdBy).toBe('client-id'); + expect(data.modifiedBy).toBe('client-id'); + }); + + it('sets only modifiedBy on UPDATE event', () => { + const data = {}; + service._setManagedUser(data, 'UPDATE', 'client-id'); + expect(data.createdBy).toBeUndefined(); + expect(data.modifiedBy).toBe('client-id'); + }); + }); + + describe('_rejectIfVirusScanLargeFile — file size threshold', () => { + let service; + let origEnv; + + beforeEach(() => { + service = new SDMAttachmentsService(); + origEnv = cds.env; + cds.env = { requires: { sdm: { settings: {} } } }; + }); + + afterEach(() => { + cds.env = origEnv; + }); + + it('returns silently when file size is below the threshold', () => { + const req = { reject: jest.fn() }; + service._rejectIfVirusScanLargeFile(req, 1024); + expect(req.reject).not.toHaveBeenCalled(); + }); + + it('rejects with 409 when file exceeds threshold and virus scan is enabled (string "true")', () => { + cds.env.requires.sdm.settings.isVirusScanEnabled = 'true'; + const req = { reject: jest.fn() }; + service._rejectIfVirusScanLargeFile(req, 500 * 1024 * 1024); // 500MB + expect(req.reject).toHaveBeenCalledWith(409, expect.any(String)); + }); + + it('rejects with 409 when file exceeds threshold and virus scan is enabled (boolean true)', () => { + cds.env.requires.sdm.settings.isVirusScanEnabled = true; + const req = { reject: jest.fn() }; + service._rejectIfVirusScanLargeFile(req, 500 * 1024 * 1024); + expect(req.reject).toHaveBeenCalledWith(409, expect.any(String)); + }); + + it('does NOT reject when file exceeds threshold but virus scan is not enabled', () => { + cds.env.requires.sdm.settings.isVirusScanEnabled = 'false'; + const req = { reject: jest.fn() }; + service._rejectIfVirusScanLargeFile(req, 500 * 1024 * 1024); + expect(req.reject).not.toHaveBeenCalled(); + }); + }); + + describe('applyClientCredentialUser', () => { + let service; + + beforeEach(() => { + service = new SDMAttachmentsService(); + getSdmClientId.mockReset(); + isClientCredentialForced.mockReset(); + }); + + it('does nothing when getSdmClientId returns null', () => { + getSdmClientId.mockReturnValue(null); + const req = { event: 'CREATE', target: { '@SDM.useClientCredential': true }, data: {} }; + service.applyClientCredentialUser(req); + expect(req.data.createdBy).toBeUndefined(); + expect(req.data.modifiedBy).toBeUndefined(); + }); + + it('sets createdBy and modifiedBy on direct CREATE when annotation is set', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(true); + const req = { event: 'CREATE', target: { '@SDM.useClientCredential': true }, data: { filename: 'a.pdf' } }; + service.applyClientCredentialUser(req); + expect(req.data.createdBy).toBe('sb-clientid-xyz'); + expect(req.data.modifiedBy).toBe('sb-clientid-xyz'); + }); + + it('sets only modifiedBy on UPDATE', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(true); + const req = { event: 'UPDATE', target: { '@SDM.useClientCredential': true }, data: { filename: 'a.pdf' } }; + service.applyClientCredentialUser(req); + expect(req.data.createdBy).toBeUndefined(); + expect(req.data.modifiedBy).toBe('sb-clientid-xyz'); + }); + + it('sets createdBy and modifiedBy on PUT', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(true); + const req = { event: 'PUT', target: { '@SDM.useClientCredential': true }, data: {} }; + service.applyClientCredentialUser(req); + expect(req.data.createdBy).toBe('sb-clientid-xyz'); + expect(req.data.modifiedBy).toBe('sb-clientid-xyz'); + }); + + it('does nothing when annotation is absent on direct attachment target', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + const req = { event: 'CREATE', target: { elements: {} }, data: {} }; + service.applyClientCredentialUser(req); + expect(req.data.createdBy).toBeUndefined(); + expect(req.data.modifiedBy).toBeUndefined(); + }); + + it('walks composition rows on parent SAVE when composition target has annotation', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + cds.model.definitions['Test.Refs'] = { '@SDM.useClientCredential': true }; + cds.model.definitions['Test.OtherRefs'] = { '@SDM.useClientCredential': false }; + const req = { + event: 'SAVE', + target: { + elements: { + references: { type: 'cds.Composition', target: 'Test.Refs' }, + other: { type: 'cds.Composition', target: 'Test.OtherRefs' } + } + }, + data: { + references: [ + { ID: '1', filename: 'a.pdf' }, + { ID: '2', filename: 'b.pdf', _op: 'update' }, + { ID: '3', _op: 'delete' } + ], + other: [{ ID: '4', filename: 'c.pdf' }] + } + }; + service.applyClientCredentialUser(req); + // annotated composition rows get the override (delete row skipped) + expect(req.data.references[0].createdBy).toBe('sb-clientid-xyz'); + expect(req.data.references[0].modifiedBy).toBe('sb-clientid-xyz'); + expect(req.data.references[1].createdBy).toBeUndefined(); + expect(req.data.references[1].modifiedBy).toBe('sb-clientid-xyz'); + expect(req.data.references[2].createdBy).toBeUndefined(); + expect(req.data.references[2].modifiedBy).toBeUndefined(); + // unannotated composition rows untouched + expect(req.data.other[0].createdBy).toBeUndefined(); + expect(req.data.other[0].modifiedBy).toBeUndefined(); + }); + + it('does NOT stamp rows with _op === "read" (PR review fix: untouched rows must not be marked as modified)', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + cds.model.definitions['Test.ReadRefs'] = { '@SDM.useClientCredential': true }; + const req = { + event: 'SAVE', + target: { + elements: { + references: { type: 'cds.Composition', target: 'Test.ReadRefs' } + } + }, + data: { + references: [ + { ID: '1', filename: 'a.pdf', _op: 'create' }, + { ID: '2', filename: 'b.pdf', _op: 'read' }, // untouched — should NOT be stamped + { ID: '3', filename: 'c.pdf', _op: 'update' } + ] + } + }; + service.applyClientCredentialUser(req); + // create row → both fields stamped + expect(req.data.references[0].createdBy).toBe('sb-clientid-xyz'); + expect(req.data.references[0].modifiedBy).toBe('sb-clientid-xyz'); + // read row → completely untouched (the regression this guards against) + expect(req.data.references[1].createdBy).toBeUndefined(); + expect(req.data.references[1].modifiedBy).toBeUndefined(); + // update row → only modifiedBy stamped + expect(req.data.references[2].createdBy).toBeUndefined(); + expect(req.data.references[2].modifiedBy).toBe('sb-clientid-xyz'); + }); + + it('does nothing on SAVE when no composition has the annotation', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + cds.model.definitions['Test.Plain'] = { '@SDM.useClientCredential': false }; + const req = { + event: 'SAVE', + target: { + elements: { plain: { type: 'cds.Composition', target: 'Test.Plain' } } + }, + data: { plain: [{ ID: '1' }] } + }; + service.applyClientCredentialUser(req); + expect(req.data.plain[0].createdBy).toBeUndefined(); + expect(req.data.plain[0].modifiedBy).toBeUndefined(); + }); + + it('returns early when req.event is not SAVE/CREATE/UPDATE and annotation absent', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + const req = { event: 'DELETE', target: { elements: {} }, data: { foo: 'bar' } }; + service.applyClientCredentialUser(req); + // No managed fields stamped — DELETE is not a write event + expect(req.data.createdBy).toBeUndefined(); + expect(req.data.modifiedBy).toBeUndefined(); + }); + + it('returns early when req.target.elements is missing', () => { + getSdmClientId.mockReturnValue('sb-clientid-xyz'); + isClientCredentialForced.mockReturnValue(false); + const req = { event: 'SAVE', target: {}, data: {} }; + expect(() => service.applyClientCredentialUser(req)).not.toThrow(); + }); + }); + // --------------------------------------------------------------------------- // Branch coverage: sdm.js uncovered lines // --------------------------------------------------------------------------- @@ -7619,7 +8148,9 @@ describe("SDMAttachmentsService", () => { it("returns cached destination on second call without calling getDestinationFromServiceBinding again", async () => { const service = new SDMAttachmentsService(); const mockDest = { url: "http://cached/" }; - const req = { _sdmDestination: mockDest }; + // HEAD's getDestination uses per-composition caching via req._sdmDestinations[cacheKey]. + // The default cache key when no attachmentsEntity is passed is '_default'. + const req = { _sdmDestinations: { _default: mockDest } }; const result = await service.getDestination(req); expect(result).toBe(mockDest); @@ -7860,6 +8391,7 @@ describe("SDMAttachmentsService", () => { attachment, expect.objectContaining({ 'cmis:description': 'New note for non-draft' }), {}, + expect.anything(), expect.anything() ); }); diff --git a/test/lib/util/index.test.js b/test/lib/util/index.test.js index c2a1118b..e5614310 100644 --- a/test/lib/util/index.test.js +++ b/test/lib/util/index.test.js @@ -1218,6 +1218,144 @@ describe("util", () => { expect(result).toBeNull(); }); }); + + describe("getSdmClientId", () => { + const originalEnv = process.env.VCAP_SERVICES; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.VCAP_SERVICES = originalEnv; + } else { + delete process.env.VCAP_SERVICES; + } + }); + + it("should extract clientid from SDM service binding's UAA credentials", () => { + process.env.VCAP_SERVICES = JSON.stringify({ + sdm: [ + { + name: "my-sdm", + credentials: { uaa: { clientid: "sb-clientid-xyz" } } + } + ] + }); + + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBe("sb-clientid-xyz"); + }); + + it("should return null when sdm service is missing", () => { + process.env.VCAP_SERVICES = JSON.stringify({ other: [] }); + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBeNull(); + }); + + it("should return null when uaa.clientid is missing", () => { + process.env.VCAP_SERVICES = JSON.stringify({ + sdm: [{ name: "my-sdm", credentials: { uaa: {} } }] + }); + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBeNull(); + }); + + it("should return null when VCAP_SERVICES is unset", () => { + delete process.env.VCAP_SERVICES; + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBeNull(); + }); + + it("should return null on malformed VCAP_SERVICES", () => { + process.env.VCAP_SERVICES = "not-json"; + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBeNull(); + }); + + it("should re-parse VCAP_SERVICES when the env var changes (cache invalidation)", () => { + // Verifies that the VCAP cache keyed on the raw string invalidates + // correctly when the env var is reassigned — important for tests + // that swap VCAP_SERVICES between scenarios. + process.env.VCAP_SERVICES = JSON.stringify({ + sdm: [{ name: "first", credentials: { uaa: { clientid: "first-id" } } }] + }); + const { getSdmClientId } = require("../../../lib/util/index"); + expect(getSdmClientId()).toBe("first-id"); + + // Reassign — the cache MUST invalidate + process.env.VCAP_SERVICES = JSON.stringify({ + sdm: [{ name: "second", credentials: { uaa: { clientid: "second-id" } } }] + }); + expect(getSdmClientId()).toBe("second-id"); + }); + }); + + describe("isClientCredentialForced", () => { + const ANNOT = '@SDM.useClientCredential'; + + beforeEach(() => { + cds.model = { definitions: {} }; + }); + + it("should return true when explicit attachmentsEntity has the annotation", () => { + const { isClientCredentialForced } = require("../../../lib/util/index"); + const entity = { [ANNOT]: true }; + expect(isClientCredentialForced({}, entity)).toBe(true); + }); + + it("should return false when explicit attachmentsEntity lacks the annotation, even if a sibling has it", () => { + // Explicit entity wins — per-composition selection. + cds.model.definitions['Test.AnnotatedSibling'] = { [ANNOT]: true }; + const req = { + target: { + elements: { + annotated: { type: 'cds.Composition', target: 'Test.AnnotatedSibling' } + } + } + }; + const { isClientCredentialForced } = require("../../../lib/util/index"); + const unannotatedEntity = {}; + expect(isClientCredentialForced(req, unannotatedEntity)).toBe(false); + }); + + it("should return true when annotation is on req.target directly", () => { + const { isClientCredentialForced } = require("../../../lib/util/index"); + const req = { target: { [ANNOT]: true } }; + expect(isClientCredentialForced(req)).toBe(true); + }); + + it("should fall back to scanning composition targets on the parent", () => { + cds.model.definitions['Test.AnnotatedComp'] = { [ANNOT]: true }; + cds.model.definitions['Test.PlainComp'] = {}; + const req = { + target: { + elements: { + annotated: { type: 'cds.Composition', target: 'Test.AnnotatedComp' }, + other: { type: 'cds.Composition', target: 'Test.PlainComp' } + } + } + }; + const { isClientCredentialForced } = require("../../../lib/util/index"); + expect(isClientCredentialForced(req)).toBe(true); + }); + + it("should return false when no annotation anywhere", () => { + cds.model.definitions['Test.PlainOnly'] = {}; + const req = { + target: { + elements: { + other: { type: 'cds.Composition', target: 'Test.PlainOnly' } + } + } + }; + const { isClientCredentialForced } = require("../../../lib/util/index"); + expect(isClientCredentialForced(req)).toBe(false); + }); + + it("should return false when req.target has no elements", () => { + const { isClientCredentialForced } = require("../../../lib/util/index"); + expect(isClientCredentialForced({ target: {} })).toBe(false); + expect(isClientCredentialForced({})).toBe(false); + }); + }); }); describe("getPropertyTitles edge cases", () => {