From 696d3ae8f81a63f4b335cf7e8544b984c8c4f8d5 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 20 Jul 2026 00:19:40 +0100 Subject: [PATCH] fix: normalize space id in SpaceCategoryPlugin so category links apply to spaces - EXO-88506 Category links are generic metadata keyed on (objectType, objectId), but the space UI/REST reads a denormalized space.getCategoryIds() kept in sync only by CategoryLinkModifiedListener, whose SPACE branch resolves the space via spaceService.getSpaceById(object.getId()) and therefore requires the space technical id. SpaceCategoryPlugin did not override getObject(), so when a caller (e.g. the MCP tooling) passed the space identity id or its pretty name, the raw link was written/read-back under that id (false success) while the listener resolved the wrong space or null, leaving space.categoryIds untouched and the UI empty. Override CategoryPlugin.getObject() to normalize any incoming space id form (technical id, social Identity id or pretty name) to the canonical technical id String.valueOf(space.getSpaceId()). Since link()/getLinkedIds() and the listener all funnel through getObject(), the write, the read-back and the denormalization now agree regardless of which id was supplied. Unresolvable ids are returned unchanged rather than silently corrupted. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 70db850e0b411aa2261726e6de437bd5e8012769) --- .../space/plugin/SpaceCategoryPlugin.java | 68 ++++++++++ .../service/CategoryLinkServiceTest.java | 27 ++++ .../plugin/SpaceCategoryPluginUnitTest.java | 120 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 component/core/src/test/java/io/meeds/social/space/plugin/SpaceCategoryPluginUnitTest.java diff --git a/component/core/src/main/java/io/meeds/social/space/plugin/SpaceCategoryPlugin.java b/component/core/src/main/java/io/meeds/social/space/plugin/SpaceCategoryPlugin.java index de837609cbd..637406f0fc3 100644 --- a/component/core/src/main/java/io/meeds/social/space/plugin/SpaceCategoryPlugin.java +++ b/component/core/src/main/java/io/meeds/social/space/plugin/SpaceCategoryPlugin.java @@ -20,11 +20,17 @@ import java.util.List; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.exoplatform.social.core.identity.model.Identity; +import org.exoplatform.social.core.identity.provider.SpaceIdentityProvider; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import io.meeds.social.category.model.CategoryObject; import io.meeds.social.category.plugin.CategoryPlugin; @Component @@ -35,6 +41,9 @@ public class SpaceCategoryPlugin implements CategoryPlugin { @Autowired private SpaceService spaceService; + @Autowired + private IdentityManager identityManager; + @Override public String getType() { return OBJECT_TYPE; @@ -55,4 +64,63 @@ public List getCategoryIds() { return spaceService.getSpaceCategoryIds(); } + /** + * Normalizes the identifier carried by the incoming {@link CategoryObject} to + * the canonical space technical id ({@link Space#getSpaceId()}) before the + * link is stored, read back or broadcast. Category links are stored generically + * on {@code (type, id)}, but the space UI/REST relies on the denormalized + * {@code space.getCategoryIds()} that is only kept in sync by + * {@code CategoryLinkModifiedListener}, whose SPACE branch resolves the space + * through {@code spaceService.getSpaceById(object.getId())} and therefore + * requires the technical id. Callers (e.g. the MCP tooling) may pass the space + * identity id or its pretty name instead; normalizing here makes the write, the + * read-back and the denormalization listener all agree whichever id form was + * supplied. + * + * @param categoryObject The incoming {@link CategoryObject} whose id may be a + * space technical id, a social {@link Identity} id or a pretty name + * @return A {@link CategoryObject} carrying the canonical space technical id, + * or the object unchanged when the id cannot be resolved to a space + **/ + @Override + public CategoryObject getObject(CategoryObject categoryObject) { + Space space = resolveSpace(categoryObject.getId()); + if (space == null) { + return categoryObject; + } + return new CategoryObject(categoryObject.getType(), + String.valueOf(space.getSpaceId()), + categoryObject.getParentId(), + categoryObject.getSpaceId()); + } + + /** + * Resolves a {@link Space} from any of the id forms a space can be referenced + * by across the platform: a space technical id, a social {@link Identity} id + * (mapped to its space through the space identity provider's remote id, i.e. + * the space pretty name) or a pretty name. This mirrors the space resolution + * idiom used by {@code SpacePermanentLinkPlugin#getDirectAccessUrl}. + * + * @param id A space technical id, social {@link Identity} id or pretty name + * @return The resolved {@link Space}, or {@code null} when none matches + **/ + private Space resolveSpace(String id) { + if (StringUtils.isBlank(id)) { + return null; + } + if (StringUtils.isNumeric(id)) { + Space space = spaceService.getSpaceById(id); + if (space != null) { + return space; + } + // Fall back to a social Identity id pointing at the space + Identity identity = identityManager.getIdentity(id); + if (identity != null && SpaceIdentityProvider.NAME.equals(identity.getProviderId())) { + return spaceService.getSpaceByPrettyName(identity.getRemoteId()); + } + return null; + } + return spaceService.getSpaceByPrettyName(id); + } + } diff --git a/component/core/src/test/java/io/meeds/social/category/service/CategoryLinkServiceTest.java b/component/core/src/test/java/io/meeds/social/category/service/CategoryLinkServiceTest.java index b7690bf8412..70156f39069 100644 --- a/component/core/src/test/java/io/meeds/social/category/service/CategoryLinkServiceTest.java +++ b/component/core/src/test/java/io/meeds/social/category/service/CategoryLinkServiceTest.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.Locale; +import org.apache.commons.collections4.CollectionUtils; import org.junit.Test; import org.exoplatform.commons.ObjectAlreadyExistsException; @@ -129,4 +130,30 @@ public void testUnLink() { assertFalse(categoryLinkService.isLinked(rootCategory.getId(), object)); } + @Test + @SneakyThrows + public void testLinkBySpaceIdentityIdDenormalizesCategoryIds() { + Space space = new Space(); + space.setRegistration(Space.OPEN); + space.setVisibility(Space.PUBLIC); + space = spaceService.createSpace(space, ROOT_USER); + + Category rootCategory = categoryService.getRootCategory(getAdminGroupIdentityId()); + + // Link the space using its social Identity id (how spaces are referenced + // across the platform) instead of its technical id, as the MCP tooling does + String spaceIdentityId = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()).getId(); + CategoryObject object = new CategoryObject(SpaceCategoryPlugin.OBJECT_TYPE, spaceIdentityId, space.getSpaceId()); + categoryLinkService.link(rootCategory.getId(), object, ROOT_USER); + + // The link is stored/read-back under the normalized technical id + assertTrue(categoryLinkService.isLinked(rootCategory.getId(), object)); + + // And the denormalization listener kept space.categoryIds in sync + restartTransaction(); + Space updatedSpace = spaceService.getSpaceById(space.getId()); + assertTrue(CollectionUtils.isNotEmpty(updatedSpace.getCategoryIds()) + && updatedSpace.getCategoryIds().contains(rootCategory.getId())); + } + } diff --git a/component/core/src/test/java/io/meeds/social/space/plugin/SpaceCategoryPluginUnitTest.java b/component/core/src/test/java/io/meeds/social/space/plugin/SpaceCategoryPluginUnitTest.java new file mode 100644 index 00000000000..0cd4c936967 --- /dev/null +++ b/component/core/src/test/java/io/meeds/social/space/plugin/SpaceCategoryPluginUnitTest.java @@ -0,0 +1,120 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2025 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.space.plugin; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit4.SpringRunner; + +import org.exoplatform.social.core.identity.model.Identity; +import org.exoplatform.social.core.identity.provider.SpaceIdentityProvider; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; + +import io.meeds.social.category.model.CategoryObject; + +@SpringBootTest(classes = { + SpaceCategoryPlugin.class, +}) +@RunWith(SpringRunner.class) +public class SpaceCategoryPluginUnitTest { + + private static final long SPACE_TECHNICAL_ID = 42l; + + private static final long SPACE_IDENTITY_ID = 74456l; + + private static final String SPACE_PRETTY_NAME = "prettyName"; + + @MockBean + private SpaceService spaceService; + + @MockBean + private IdentityManager identityManager; + + @Autowired + private SpaceCategoryPlugin spaceCategoryPlugin; + + @Test + public void testGetObjectNormalizesTechnicalId() { + Space space = newSpace(); + when(spaceService.getSpaceById(String.valueOf(SPACE_TECHNICAL_ID))).thenReturn(space); + + CategoryObject object = new CategoryObject(SpaceCategoryPlugin.OBJECT_TYPE, String.valueOf(SPACE_TECHNICAL_ID), 0l); + CategoryObject normalized = spaceCategoryPlugin.getObject(object); + + assertEquals(String.valueOf(SPACE_TECHNICAL_ID), normalized.getId()); + assertEquals(SpaceCategoryPlugin.OBJECT_TYPE, normalized.getType()); + } + + @Test + public void testGetObjectNormalizesIdentityIdToTechnicalId() { + Space space = newSpace(); + // The identity id is not a space technical id + when(spaceService.getSpaceById(String.valueOf(SPACE_IDENTITY_ID))).thenReturn(null); + Identity identity = mock(Identity.class); + when(identity.getProviderId()).thenReturn(SpaceIdentityProvider.NAME); + when(identity.getRemoteId()).thenReturn(SPACE_PRETTY_NAME); + when(identityManager.getIdentity(String.valueOf(SPACE_IDENTITY_ID))).thenReturn(identity); + when(spaceService.getSpaceByPrettyName(SPACE_PRETTY_NAME)).thenReturn(space); + + CategoryObject object = new CategoryObject(SpaceCategoryPlugin.OBJECT_TYPE, String.valueOf(SPACE_IDENTITY_ID), 0l); + CategoryObject normalized = spaceCategoryPlugin.getObject(object); + + assertEquals(String.valueOf(SPACE_TECHNICAL_ID), normalized.getId()); + } + + @Test + public void testGetObjectNormalizesPrettyNameToTechnicalId() { + Space space = newSpace(); + when(spaceService.getSpaceByPrettyName(SPACE_PRETTY_NAME)).thenReturn(space); + + CategoryObject object = new CategoryObject(SpaceCategoryPlugin.OBJECT_TYPE, SPACE_PRETTY_NAME, 0l); + CategoryObject normalized = spaceCategoryPlugin.getObject(object); + + assertEquals(String.valueOf(SPACE_TECHNICAL_ID), normalized.getId()); + } + + @Test + public void testGetObjectReturnsSameObjectWhenUnresolvable() { + when(spaceService.getSpaceById("unknown")).thenReturn(null); + when(spaceService.getSpaceByPrettyName("unknown")).thenReturn(null); + + CategoryObject object = new CategoryObject(SpaceCategoryPlugin.OBJECT_TYPE, "unknown", 0l); + CategoryObject normalized = spaceCategoryPlugin.getObject(object); + + assertSame(object, normalized); + } + + private Space newSpace() { + Space space = new Space(); + space.setId(String.valueOf(SPACE_TECHNICAL_ID)); + space.setPrettyName(SPACE_PRETTY_NAME); + return space; + } + +}