From f27616ad8d342f6a32bcfb67f388953d48540b8c Mon Sep 17 00:00:00 2001 From: Mitrofanos Ntatidis Date: Mon, 24 Aug 2026 17:27:45 +0100 Subject: [PATCH] fix(ipa): correct parent-resource check in resourceBelongsToSingleParent Drop one path segment instead of two when normalizing an instance path, export the function, and add unit tests. No change to rule output. JIRA: CLOUDP-438303 --- .../utils/resourceEvaluation.test.js | 35 +++++++++++++++++++ .../functions/utils/resourceEvaluation.js | 25 ++++++------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js index 6dd83b664b..f92a809fb7 100644 --- a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js +++ b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js @@ -7,6 +7,7 @@ import { isRootLevelResource, isSingleResourceIdentifier, isSingletonResource, + resourceBelongsToSingleParent, } from '../../rulesets/functions/utils/resourceEvaluation'; const resource = { @@ -608,4 +609,38 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () expect(isRootLevelResource('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); }); }); + + describe('resourceBelongsToSingleParent', () => { + it('recognizes nested collection paths', () => { + expect(resourceBelongsToSingleParent('/groups/{groupId}/clusters')).toBe(true); + expect(resourceBelongsToSingleParent('/api/atlas/v2/groups/{groupId}/clusters')).toBe(true); + expect(resourceBelongsToSingleParent('/api/atlas/v2/unauth/groups/{groupId}/clusters')).toBe(true); + expect(resourceBelongsToSingleParent('/groups/{groupId}/clusters/{clusterName}/processArgs')).toBe(true); + }); + + it('recognizes nested instance paths', () => { + expect(resourceBelongsToSingleParent('/groups/{groupId}/clusters/{clusterId}')).toBe(true); + expect(resourceBelongsToSingleParent('/groups/{g}/clusters/{c}/processes/{p}')).toBe(true); + }); + + it('rejects root-level resources', () => { + expect(resourceBelongsToSingleParent('/groups')).toBe(false); + expect(resourceBelongsToSingleParent('/groups/{groupId}')).toBe(false); + expect(resourceBelongsToSingleParent('/api/atlas/v2/groups/{groupId}')).toBe(false); + }); + + it('rejects resources nested under a non-parameterized segment', () => { + expect(resourceBelongsToSingleParent('/groups/settings')).toBe(false); + expect(resourceBelongsToSingleParent('/groups/settings/{settingId}')).toBe(false); + }); + + it('handles paths that are only the API prefix', () => { + expect(resourceBelongsToSingleParent('/api/atlas/v2')).toBe(true); + expect(resourceBelongsToSingleParent('/api/atlas/v2/unauth')).toBe(true); + }); + + it('rejects consecutive path params', () => { + expect(resourceBelongsToSingleParent('/a/{b}/{c}')).toBe(false); + }); + }); }); diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index 2b811bfa46..607c76bf91 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -207,32 +207,27 @@ export function getResourcePathItems(resourceCollectionPath, allPathItems) { /** * Checks whether a resource belongs to one parent resource. * For example, '/resource' returns false, '/resource/{id}/child' returns true. + * Instance paths are normalized to their collection path first, so + * '/parent/{parentId}/child/{childId}' returns true. * * @param {string} resourcePath a path for a resource * @returns {boolean} */ -function resourceBelongsToSingleParent(resourcePath) { +export function resourceBelongsToSingleParent(resourcePath) { // Ignore /api/atlas/v2 and /api/atlas/v2/unauth const path = removePrefix(resourcePath); if (path === '') { return true; } - let resourcePathSections = path.split('/'); - if (resourcePathSections[0] === '') { - resourcePathSections.shift(); - } - if (resourcePathSections.length < 2) { - return false; - } - if (isPathParam(resourcePathSections[resourcePathSections.length - 1])) { - resourcePathSections = resourcePathSections.slice(0, resourcePathSections.length - 2); - } - if (resourcePathSections.length === 1) { - return false; + const sections = path.split('/').filter((section) => section.length > 0); + // Normalize an instance path down to its collection path, so that + // '/parent/{parentId}/child/{childId}' is evaluated as '/parent/{parentId}/child' + if (sections.length > 0 && isPathParam(sections[sections.length - 1])) { + sections.pop(); } - const parentResourceSection = resourcePathSections[resourcePathSections.length - 2]; - return isPathParam(parentResourceSection); + // The resource name is last, so the section before it holds the parent's id if there is a parent + return sections.length > 1 && isPathParam(sections[sections.length - 2]); } // TODO move prefixes to be rule arguments