From dfdeb85006f6d0c1d700252ab052a33f7c9e2b62 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 18 Aug 2026 11:56:36 +0200 Subject: [PATCH 01/17] feat: owner check scanner A new scanner, that on publish checks upstream for same extension and if exists, enforces locally that publisher is also owner of the NS. --- .../VSCodeGalleryOwnershipScanner.java | 122 +++++++++++++++++ .../VSCodeGalleryOwnershipScannerTest.java | 124 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java new file mode 100644 index 000000000..dbaeff6b7 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java @@ -0,0 +1,122 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import java.util.List; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; + +/** + * Scanner that blocks publishing to a namespace/extension identifier that already exists on the + * upstream VS Code Marketplace, unless the publishing user is a local owner (not just a + * contributor) of the namespace. Guards against namespace-squatting relative to the upstream + * gallery identity. + */ +@Component +public class VSCodeGalleryOwnershipScanner implements Scanner { + + public static final String TYPE = "vscode-gallery-ownership"; + + private final VSCodeIdService vsCodeIdService; + private final RepositoryService repositories; + private final EntityManager entityManager; + private final ScannerRegistry scannerRegistry; + + @Value("${ovsx.scanning.gallery-ownership.enabled:false}") + private boolean enabled; + @Value("${ovsx.scanning.gallery-ownership.required:false}") + private boolean required; + @Value("${ovsx.scanning.gallery-ownership.enforced:true}") + private boolean enforced; + + public VSCodeGalleryOwnershipScanner( + VSCodeIdService vsCodeIdService, + RepositoryService repositories, + EntityManager entityManager, + ScannerRegistry scannerRegistry + ) { + this.vsCodeIdService = vsCodeIdService; + this.repositories = repositories; + this.entityManager = entityManager; + this.scannerRegistry = scannerRegistry; + } + + @PostConstruct + void register() { + if (enabled) { + scannerRegistry.registerScanner(this); + } + } + + @Override + @NonNull + public String getScannerType() { + return TYPE; + } + + @Override + public boolean isRequired() { + return required; + } + + @Override + public boolean enforcesThreats() { + return enforced; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public Scanner.@NonNull Invocation startScan(@NonNull Command command) throws ScannerException { + var extVersion = entityManager.find(ExtensionVersion.class, command.extensionVersionId()); + if (extVersion == null) { + throw new ScannerException("ExtensionVersion not found: " + command.extensionVersionId()); + } + + var extension = extVersion.getExtension(); + var namespace = extension.getNamespace(); + + var upstream = vsCodeIdService.getUpstreamPublicIds(extension); + boolean existsUpstream = upstream != null && upstream.namespace() != null && upstream.extension() != null; + if (!existsUpstream) { + return new Scanner.Invocation.Completed(Scanner.Result.clean()); + } + + var publishedWith = extVersion.getPublishedWith(); + var user = publishedWith != null ? publishedWith.getUser() : null; + if (user != null && repositories.isNamespaceOwner(user, namespace)) { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Extension exists on the VS Code Marketplace; publisher confirmed as namespace owner.")); + } + + var threat = new Scanner.Threat( + "vscode-gallery-namespace-conflict", + "'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " + + "and the publishing user is not an owner of namespace '" + namespace.getName() + "'.", + "high"); + return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java new file mode 100644 index 000000000..0ba293561 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.eclipse.openvsx.adapter.PublicIds; +import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VSCodeGalleryOwnershipScannerTest { + + @Mock + private VSCodeIdService vsCodeIdService; + @Mock + private RepositoryService repositories; + @Mock + private EntityManager entityManager; + @Mock + private ScannerRegistry scannerRegistry; + + private VSCodeGalleryOwnershipScanner newScanner() { + return new VSCodeGalleryOwnershipScanner(vsCodeIdService, repositories, entityManager, scannerRegistry); + } + + private ExtensionVersion extensionVersion(UserData publisher) { + var namespace = new Namespace(); + namespace.setName("acme"); + + var extension = new Extension(); + extension.setName("widget"); + extension.setNamespace(namespace); + + var extVersion = new ExtensionVersion(); + extVersion.setExtension(extension); + if (publisher != null) { + var token = new PersonalAccessToken(); + token.setUser(publisher); + extVersion.setPublishedWith(token); + } + return extVersion; + } + + @Test + void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds(null, null)); + + var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(repositories, never()).isNamespaceOwner(any(), any()); + } + + @Test + void startScan_isClean_whenExistsUpstreamAndUserIsNamespaceOwner() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + when(repositories.isNamespaceOwner(user, extVersion.getExtension().getNamespace())).thenReturn(true); + + var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + } + + @Test + void startScan_raisesThreat_whenExistsUpstreamAndUserIsNotNamespaceOwner() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + when(repositories.isNamespaceOwner(user, extVersion.getExtension().getNamespace())).thenReturn(false); + + var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + assertEquals(1, invocation.result().getThreats().size()); + } + + @Test + void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception { + var extVersion = extensionVersion(null); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + + var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + verify(repositories, never()).isNamespaceOwner(any(), any()); + } + + @Test + void startScan_throws_whenExtensionVersionNotFound() { + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null); + + assertThrows(ScannerException.class, () -> newScanner().startScan(new Scanner.Command(1L, "scan-1"))); + } +} From 8c569d7b52fc056d87a199a0098100fb389130e4 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 18 Aug 2026 13:04:46 +0200 Subject: [PATCH 02/17] Check was wrong: is verified is needed --- .../VSCodeGalleryOwnershipScanner.java | 6 +++--- .../VSCodeGalleryOwnershipScannerTest.java | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java index dbaeff6b7..a38d712c0 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java @@ -43,7 +43,7 @@ public class VSCodeGalleryOwnershipScanner implements Scanner { @Value("${ovsx.scanning.gallery-ownership.enabled:false}") private boolean enabled; - @Value("${ovsx.scanning.gallery-ownership.required:false}") + @Value("${ovsx.scanning.gallery-ownership.required:true}") private boolean required; @Value("${ovsx.scanning.gallery-ownership.enforced:true}") private boolean enforced; @@ -106,10 +106,10 @@ public boolean isAsync() { var publishedWith = extVersion.getPublishedWith(); var user = publishedWith != null ? publishedWith.getUser() : null; - if (user != null && repositories.isNamespaceOwner(user, namespace)) { + if (user != null && repositories.isVerified(namespace, user)) { return new Scanner.Invocation.Completed( Scanner.Result.clean( - "Extension exists on the VS Code Marketplace; publisher confirmed as namespace owner.")); + "Extension exists on the VS Code Marketplace; namespace confirmed as verified.")); } var threat = new Scanner.Threat( diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java index 0ba293561..beaf79d4d 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java @@ -73,34 +73,35 @@ void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); - verify(repositories, never()).isNamespaceOwner(any(), any()); + verify(repositories, never()).isVerified(any(), any()); } @Test - void startScan_isClean_whenExistsUpstreamAndUserIsNamespaceOwner() throws Exception { + void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws Exception { var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); - when(repositories.isNamespaceOwner(user, extVersion.getExtension().getNamespace())).thenReturn(true); + when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); - assertTrue(invocation.result().isClean()); + assertFalse(invocation.result().isClean()); + assertEquals(1, invocation.result().getThreats().size()); } @Test - void startScan_raisesThreat_whenExistsUpstreamAndUserIsNotNamespaceOwner() throws Exception { + void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Exception { var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); - when(repositories.isNamespaceOwner(user, extVersion.getExtension().getNamespace())).thenReturn(false); + when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); - assertFalse(invocation.result().isClean()); - assertEquals(1, invocation.result().getThreats().size()); + assertTrue(invocation.result().isClean()); + assertEquals(0, invocation.result().getThreats().size()); } @Test @@ -112,7 +113,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); - verify(repositories, never()).isNamespaceOwner(any(), any()); + verify(repositories, never()).isVerified(any(), any()); } @Test From 4eb976415a8434ee8270584a262fde27d9aa460d Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 18 Aug 2026 15:14:06 +0200 Subject: [PATCH 03/17] Fixes --- .../VSCodeGalleryNSVerifiedCheckConfig.java | 100 ++++++++++ .../VSCodeGalleryNSVerifiedCheckScanner.java | 171 ++++++++++++++++++ .../VSCodeGalleryOwnershipScanner.java | 122 ------------- ...odeGalleryNSVerifiedCheckScannerTest.java} | 72 ++++++-- 4 files changed, 331 insertions(+), 134 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java delete mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java rename server/src/test/java/org/eclipse/openvsx/scanning/{VSCodeGalleryOwnershipScannerTest.java => VSCodeGalleryNSVerifiedCheckScannerTest.java} (60%) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java new file mode 100644 index 000000000..65d374b8f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java @@ -0,0 +1,100 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for {@link VSCodeGalleryNSVerifiedCheckScanner}, extracted, to simplify testing. + */ +@Configuration +public class VSCodeGalleryNSVerifiedCheckConfig { + /** + * Is NS verification check for upstream existing extensions enabled or not. + *

+ * Property: {@code ovsx.scanning.gallery-ownership.enabled} + * Default: {@code false} + */ + @Value("${ovsx.scanning.gallery-ownership.enabled:false}") + private boolean enabled; + + /** + * Is NS verification check for upstream existing extensions required or not. + *

+ * Property: {@code ovsx.scanning.gallery-ownership.required} + * Default: {@code true} + */ + @Value("${ovsx.scanning.gallery-ownership.required:true}") + private boolean required; + + /** + * Is NS verification check for upstream existing extensions enforced or not. + *

+ * Property: {@code ovsx.scanning.gallery-ownership.enforced} + * Default: {@code true} + */ + @Value("${ovsx.scanning.gallery-ownership.enforced:true}") + private boolean enforced; + + /** + * The upstream gallery API URL to perform the existence checks against. + *

+ * Property: {@code ovsx.scanning.gallery-ownership.gallery-url} + * Default: {@code ""} + */ + @Value("${ovsx.scanning.gallery-ownership.gallery-url:}") + private String galleryUrl; + + /** + * Default constructor. + */ + public VSCodeGalleryNSVerifiedCheckConfig() { + } + + /** + * For testing. + */ + public VSCodeGalleryNSVerifiedCheckConfig(boolean enabled, boolean required, boolean enforced, String galleryUrl) { + this.enabled = enabled; + this.required = required; + this.enforced = enforced; + this.galleryUrl = galleryUrl; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isRequired() { + return required; + } + + public boolean isEnforced() { + return enforced; + } + + public String getGalleryUrl() { + return galleryUrl; + } + + @PostConstruct + public void validate() { + if (enabled) { + if (galleryUrl == null || galleryUrl.isEmpty()) { + throw new IllegalStateException("ovsx.scanning.gallery-ownership.gallery-url must be set"); + } + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java new file mode 100644 index 000000000..f67497048 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java @@ -0,0 +1,171 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import java.util.List; +import java.util.Optional; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.adapter.ExtensionQueryParam; +import org.eclipse.openvsx.adapter.ExtensionQueryResult; +import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; +import org.eclipse.openvsx.util.UrlUtil; + +/** + * Scanner that blocks publishing to a namespace/extension identifier that already exists on the + * upstream VS Code Marketplace, unless the publishing NS is verified (has owner not only + * contributors) namespace. Guards against namespace-squatting relative to the upstream + * gallery identity. + */ +@Component +public class VSCodeGalleryNSVerifiedCheckScanner implements Scanner { + + public static final String TYPE = "vscode-gallery-ownership"; + + private final VSCodeGalleryNSVerifiedCheckConfig config; + private final RestTemplate restTemplate; + private final RepositoryService repositories; + private final EntityManager entityManager; + private final ScannerRegistry scannerRegistry; + + public VSCodeGalleryNSVerifiedCheckScanner( + VSCodeGalleryNSVerifiedCheckConfig config, + RestTemplate restTemplate, + RepositoryService repositories, + EntityManager entityManager, + ScannerRegistry scannerRegistry + ) { + this.config = config; + this.restTemplate = restTemplate; + this.repositories = repositories; + this.entityManager = entityManager; + this.scannerRegistry = scannerRegistry; + } + + @PostConstruct + void register() { + if (config.isEnabled()) { + scannerRegistry.registerScanner(this); + } + } + + @Override + @NonNull + public String getScannerType() { + return TYPE; + } + + @Override + public boolean isRequired() { + return config.isRequired(); + } + + @Override + public boolean enforcesThreats() { + return config.isEnforced(); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public Scanner.@NonNull Invocation startScan(@NonNull Command command) throws ScannerException { + var extVersion = entityManager.find(ExtensionVersion.class, command.extensionVersionId()); + if (extVersion == null) { + throw new ScannerException("ExtensionVersion not found: " + command.extensionVersionId()); + } + + var extension = extVersion.getExtension(); + var namespace = extension.getNamespace(); + + Optional upstreamExists = upstreamExists(extension); + if (upstreamExists.isEmpty()) { + throw new ScannerException("Failed to perform " + TYPE); + } else { + boolean upstreamDoesExists = upstreamExists.orElseThrow(); + if (!upstreamDoesExists) { + return new Scanner.Invocation.Completed(Scanner.Result.clean()); + } + } + + var publishedWith = extVersion.getPublishedWith(); + var user = publishedWith != null ? publishedWith.getUser() : null; + if (user != null && repositories.isVerified(namespace, user)) { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Extension exists on the VS Code Marketplace; namespace confirmed as verified.")); + } + + var threat = new Scanner.Threat( + "vscode-gallery-namespace-conflict", + "'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " + + "and the publishing user is not an owner of namespace '" + namespace.getName() + "'.", + "high"); + return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); + } + + /** + * Method reaching upstream; if return Optional is empty, check is not definitive (ie. remote end is down or + * unreachable). It will return non-empty optional wrapped boolean only if it has definitive answer, whether + * remote end have or does not have extension. + */ + private Optional upstreamExists(Extension extension) { + var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); + var requestData = new ExtensionQueryParam( + List.of( + new ExtensionQueryParam.Filter( + List.of( + new ExtensionQueryParam.Criterion( + ExtensionQueryParam.Criterion.FILTER_TARGET, + "Microsoft.VisualStudio.Code"), + new ExtensionQueryParam.Criterion( + ExtensionQueryParam.Criterion.FILTER_EXTENSION_NAME, + NamingUtil.toExtensionId(extension))), + 1, + 1, + 0, + 0)), + 0); + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.ACCEPT, "application/json;api-version=" + VSCodeIdService.API_VERSION); + try { + var result = restTemplate + .postForObject(requestUrl, new HttpEntity<>(requestData, headers), ExtensionQueryResult.class); + if (result != null && result.results() != null && !result.results().isEmpty()) { + var item = result.results().getFirst(); + if (item.extensions() != null && !item.extensions().isEmpty()) { + return Optional.of(Boolean.TRUE); + } + } + return Optional.of(Boolean.FALSE); + } catch (RestClientException e) { + return Optional.empty(); // ie upstream is down or whatever; we have no definite answer + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java deleted file mode 100644 index a38d712c0..000000000 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScanner.java +++ /dev/null @@ -1,122 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * https://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ -package org.eclipse.openvsx.scanning; - -import java.util.List; - -import jakarta.annotation.PostConstruct; -import jakarta.persistence.EntityManager; -import org.jspecify.annotations.NonNull; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import org.eclipse.openvsx.adapter.VSCodeIdService; -import org.eclipse.openvsx.entities.ExtensionVersion; -import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.util.NamingUtil; - -/** - * Scanner that blocks publishing to a namespace/extension identifier that already exists on the - * upstream VS Code Marketplace, unless the publishing user is a local owner (not just a - * contributor) of the namespace. Guards against namespace-squatting relative to the upstream - * gallery identity. - */ -@Component -public class VSCodeGalleryOwnershipScanner implements Scanner { - - public static final String TYPE = "vscode-gallery-ownership"; - - private final VSCodeIdService vsCodeIdService; - private final RepositoryService repositories; - private final EntityManager entityManager; - private final ScannerRegistry scannerRegistry; - - @Value("${ovsx.scanning.gallery-ownership.enabled:false}") - private boolean enabled; - @Value("${ovsx.scanning.gallery-ownership.required:true}") - private boolean required; - @Value("${ovsx.scanning.gallery-ownership.enforced:true}") - private boolean enforced; - - public VSCodeGalleryOwnershipScanner( - VSCodeIdService vsCodeIdService, - RepositoryService repositories, - EntityManager entityManager, - ScannerRegistry scannerRegistry - ) { - this.vsCodeIdService = vsCodeIdService; - this.repositories = repositories; - this.entityManager = entityManager; - this.scannerRegistry = scannerRegistry; - } - - @PostConstruct - void register() { - if (enabled) { - scannerRegistry.registerScanner(this); - } - } - - @Override - @NonNull - public String getScannerType() { - return TYPE; - } - - @Override - public boolean isRequired() { - return required; - } - - @Override - public boolean enforcesThreats() { - return enforced; - } - - @Override - public boolean isAsync() { - return false; - } - - @Override - public Scanner.@NonNull Invocation startScan(@NonNull Command command) throws ScannerException { - var extVersion = entityManager.find(ExtensionVersion.class, command.extensionVersionId()); - if (extVersion == null) { - throw new ScannerException("ExtensionVersion not found: " + command.extensionVersionId()); - } - - var extension = extVersion.getExtension(); - var namespace = extension.getNamespace(); - - var upstream = vsCodeIdService.getUpstreamPublicIds(extension); - boolean existsUpstream = upstream != null && upstream.namespace() != null && upstream.extension() != null; - if (!existsUpstream) { - return new Scanner.Invocation.Completed(Scanner.Result.clean()); - } - - var publishedWith = extVersion.getPublishedWith(); - var user = publishedWith != null ? publishedWith.getUser() : null; - if (user != null && repositories.isVerified(namespace, user)) { - return new Scanner.Invocation.Completed( - Scanner.Result.clean( - "Extension exists on the VS Code Marketplace; namespace confirmed as verified.")); - } - - var threat = new Scanner.Threat( - "vscode-gallery-namespace-conflict", - "'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " + - "and the publishing user is not an owner of namespace '" + namespace.getName() + "'.", - "high"); - return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); - } -} diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java similarity index 60% rename from server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java rename to server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java index beaf79d4d..ea1deba87 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryOwnershipScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java @@ -12,14 +12,20 @@ ********************************************************************************/ package org.eclipse.openvsx.scanning; +import java.nio.charset.StandardCharsets; +import java.util.List; + import jakarta.persistence.EntityManager; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; -import org.eclipse.openvsx.adapter.PublicIds; -import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.adapter.ExtensionQueryResult; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.Namespace; @@ -27,14 +33,21 @@ import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.repositories.RepositoryService; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class VSCodeGalleryOwnershipScannerTest { +class VSCodeGalleryNSVerifiedCheckScannerTest { @Mock - private VSCodeIdService vsCodeIdService; + private RestTemplate restTemplate; @Mock private RepositoryService repositories; @Mock @@ -42,8 +55,43 @@ class VSCodeGalleryOwnershipScannerTest { @Mock private ScannerRegistry scannerRegistry; - private VSCodeGalleryOwnershipScanner newScanner() { - return new VSCodeGalleryOwnershipScanner(vsCodeIdService, repositories, entityManager, scannerRegistry); + /** + * Dummy result; irrelevant of content + */ + private final ExtensionQueryResult dummyResult = new ExtensionQueryResult( + List.of( + new ExtensionQueryResult.ResultItem( + List.of( + new ExtensionQueryResult.Extension( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null)), + List.of()))); + + /** + * Dummy result; irrelevant of content + */ + private final ExtensionQueryResult emptyResult = new ExtensionQueryResult( + List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); + + private VSCodeGalleryNSVerifiedCheckScanner newScanner() { + var config = new VSCodeGalleryNSVerifiedCheckConfig(true, true, true, "http://irrelevant"); + return new VSCodeGalleryNSVerifiedCheckScanner( + config, + restTemplate, + repositories, + entityManager, + scannerRegistry); } private ExtensionVersion extensionVersion(UserData publisher) { @@ -68,7 +116,7 @@ private ExtensionVersion extensionVersion(UserData publisher) { void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { var extVersion = extensionVersion(new UserData()); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds(null, null)); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(emptyResult); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); @@ -81,7 +129,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); @@ -95,7 +143,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); @@ -108,7 +156,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception { var extVersion = extensionVersion(null); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id")); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); From 04cc6c6d37f2d29ceb9024a5ea6d4f012b226541 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 18 Aug 2026 15:17:44 +0200 Subject: [PATCH 04/17] Add UT when upstream reach fails --- .../VSCodeGalleryNSVerifiedCheckScannerTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java index ea1deba87..152b1417d 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java @@ -23,6 +23,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.eclipse.openvsx.adapter.ExtensionQueryResult; @@ -112,6 +113,15 @@ private ExtensionVersion extensionVersion(UserData publisher) { return extVersion; } + @Test + void startScan_isClean_whenUpstreamIsDown() { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); + + assertThrows(ScannerException.class, () -> newScanner().startScan(new Scanner.Command(1L, "scan-1"))); + } + @Test void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { var extVersion = extensionVersion(new UserData()); From ef320a595964d97239c56eae0a77d359c2dd3b23 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 18 Aug 2026 16:07:40 +0200 Subject: [PATCH 05/17] Rename --- ...va => VSCodeGalleryExistenceCheckConfig.java} | 16 ++++++++-------- ...a => VSCodeGalleryExistenceCheckScanner.java} | 8 ++++---- ... VSCodeGalleryExistenceCheckScannerTest.java} | 12 ++++-------- 3 files changed, 16 insertions(+), 20 deletions(-) rename server/src/main/java/org/eclipse/openvsx/scanning/{VSCodeGalleryNSVerifiedCheckConfig.java => VSCodeGalleryExistenceCheckConfig.java} (80%) rename server/src/main/java/org/eclipse/openvsx/scanning/{VSCodeGalleryNSVerifiedCheckScanner.java => VSCodeGalleryExistenceCheckScanner.java} (96%) rename server/src/test/java/org/eclipse/openvsx/scanning/{VSCodeGalleryNSVerifiedCheckScannerTest.java => VSCodeGalleryExistenceCheckScannerTest.java} (93%) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java similarity index 80% rename from server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java rename to server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java index 65d374b8f..67ba7fd89 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java @@ -17,14 +17,14 @@ import org.springframework.context.annotation.Configuration; /** - * Configuration for {@link VSCodeGalleryNSVerifiedCheckScanner}, extracted, to simplify testing. + * Configuration for {@link VSCodeGalleryExistenceCheckScanner}, extracted, to simplify testing. */ @Configuration -public class VSCodeGalleryNSVerifiedCheckConfig { +public class VSCodeGalleryExistenceCheckConfig { /** * Is NS verification check for upstream existing extensions enabled or not. *

- * Property: {@code ovsx.scanning.gallery-ownership.enabled} + * Property: {@code ovsx.scanning.gallery-existence-check.enabled} * Default: {@code false} */ @Value("${ovsx.scanning.gallery-ownership.enabled:false}") @@ -33,7 +33,7 @@ public class VSCodeGalleryNSVerifiedCheckConfig { /** * Is NS verification check for upstream existing extensions required or not. *

- * Property: {@code ovsx.scanning.gallery-ownership.required} + * Property: {@code ovsx.scanning.gallery-existence-check.required} * Default: {@code true} */ @Value("${ovsx.scanning.gallery-ownership.required:true}") @@ -42,7 +42,7 @@ public class VSCodeGalleryNSVerifiedCheckConfig { /** * Is NS verification check for upstream existing extensions enforced or not. *

- * Property: {@code ovsx.scanning.gallery-ownership.enforced} + * Property: {@code ovsx.scanning.gallery-existence-check.enforced} * Default: {@code true} */ @Value("${ovsx.scanning.gallery-ownership.enforced:true}") @@ -51,7 +51,7 @@ public class VSCodeGalleryNSVerifiedCheckConfig { /** * The upstream gallery API URL to perform the existence checks against. *

- * Property: {@code ovsx.scanning.gallery-ownership.gallery-url} + * Property: {@code ovsx.scanning.gallery-existence-check.gallery-url} * Default: {@code ""} */ @Value("${ovsx.scanning.gallery-ownership.gallery-url:}") @@ -60,13 +60,13 @@ public class VSCodeGalleryNSVerifiedCheckConfig { /** * Default constructor. */ - public VSCodeGalleryNSVerifiedCheckConfig() { + public VSCodeGalleryExistenceCheckConfig() { } /** * For testing. */ - public VSCodeGalleryNSVerifiedCheckConfig(boolean enabled, boolean required, boolean enforced, String galleryUrl) { + public VSCodeGalleryExistenceCheckConfig(boolean enabled, boolean required, boolean enforced, String galleryUrl) { this.enabled = enabled; this.required = required; this.enforced = enforced; diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java similarity index 96% rename from server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java rename to server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java index f67497048..14c8ed1c3 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java @@ -41,18 +41,18 @@ * gallery identity. */ @Component -public class VSCodeGalleryNSVerifiedCheckScanner implements Scanner { +public class VSCodeGalleryExistenceCheckScanner implements Scanner { public static final String TYPE = "vscode-gallery-ownership"; - private final VSCodeGalleryNSVerifiedCheckConfig config; + private final VSCodeGalleryExistenceCheckConfig config; private final RestTemplate restTemplate; private final RepositoryService repositories; private final EntityManager entityManager; private final ScannerRegistry scannerRegistry; - public VSCodeGalleryNSVerifiedCheckScanner( - VSCodeGalleryNSVerifiedCheckConfig config, + public VSCodeGalleryExistenceCheckScanner( + VSCodeGalleryExistenceCheckConfig config, RestTemplate restTemplate, RepositoryService repositories, EntityManager entityManager, diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java similarity index 93% rename from server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java rename to server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java index 152b1417d..ca00cba07 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryNSVerifiedCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java @@ -12,7 +12,6 @@ ********************************************************************************/ package org.eclipse.openvsx.scanning; -import java.nio.charset.StandardCharsets; import java.util.List; import jakarta.persistence.EntityManager; @@ -20,9 +19,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @@ -45,7 +41,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class VSCodeGalleryNSVerifiedCheckScannerTest { +class VSCodeGalleryExistenceCheckScannerTest { @Mock private RestTemplate restTemplate; @@ -85,9 +81,9 @@ class VSCodeGalleryNSVerifiedCheckScannerTest { private final ExtensionQueryResult emptyResult = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); - private VSCodeGalleryNSVerifiedCheckScanner newScanner() { - var config = new VSCodeGalleryNSVerifiedCheckConfig(true, true, true, "http://irrelevant"); - return new VSCodeGalleryNSVerifiedCheckScanner( + private VSCodeGalleryExistenceCheckScanner newScanner() { + var config = new VSCodeGalleryExistenceCheckConfig(true, true, true, "http://irrelevant"); + return new VSCodeGalleryExistenceCheckScanner( config, restTemplate, repositories, From a9f216182fa37b86ea130bcf532bf8ba277ab013 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 12:31:34 +0200 Subject: [PATCH 06/17] Apply PR comments --- .../VSCodeGalleryExistenceCheckConfig.java | 8 ++-- .../VSCodeGalleryExistenceCheckScanner.java | 46 +++++++++---------- ...SCodeGalleryExistenceCheckScannerTest.java | 28 +++++++---- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java index 67ba7fd89..11dd9415f 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java @@ -27,7 +27,7 @@ public class VSCodeGalleryExistenceCheckConfig { * Property: {@code ovsx.scanning.gallery-existence-check.enabled} * Default: {@code false} */ - @Value("${ovsx.scanning.gallery-ownership.enabled:false}") + @Value("${ovsx.scanning.gallery-existence-check.enabled:false}") private boolean enabled; /** @@ -36,7 +36,7 @@ public class VSCodeGalleryExistenceCheckConfig { * Property: {@code ovsx.scanning.gallery-existence-check.required} * Default: {@code true} */ - @Value("${ovsx.scanning.gallery-ownership.required:true}") + @Value("${ovsx.scanning.gallery-existence-check.required:true}") private boolean required; /** @@ -45,7 +45,7 @@ public class VSCodeGalleryExistenceCheckConfig { * Property: {@code ovsx.scanning.gallery-existence-check.enforced} * Default: {@code true} */ - @Value("${ovsx.scanning.gallery-ownership.enforced:true}") + @Value("${ovsx.scanning.gallery-existence-check.enforced:true}") private boolean enforced; /** @@ -54,7 +54,7 @@ public class VSCodeGalleryExistenceCheckConfig { * Property: {@code ovsx.scanning.gallery-existence-check.gallery-url} * Default: {@code ""} */ - @Value("${ovsx.scanning.gallery-ownership.gallery-url:}") + @Value("${ovsx.scanning.gallery-existence-check.gallery-url:}") private String galleryUrl; /** diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java index 14c8ed1c3..019df739f 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java @@ -13,7 +13,6 @@ package org.eclipse.openvsx.scanning; import java.util.List; -import java.util.Optional; import jakarta.annotation.PostConstruct; import jakarta.persistence.EntityManager; @@ -27,7 +26,7 @@ import org.eclipse.openvsx.adapter.ExtensionQueryParam; import org.eclipse.openvsx.adapter.ExtensionQueryResult; -import org.eclipse.openvsx.adapter.VSCodeIdService; +import org.eclipse.openvsx.adapter.IVSCodeService; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.repositories.RepositoryService; @@ -36,7 +35,7 @@ /** * Scanner that blocks publishing to a namespace/extension identifier that already exists on the - * upstream VS Code Marketplace, unless the publishing NS is verified (has owner not only + * upstream VS Code Marketplace, unless the publishing NS is verified (has owner, not only * contributors) namespace. Guards against namespace-squatting relative to the upstream * gallery identity. */ @@ -103,14 +102,19 @@ public boolean isAsync() { var extension = extVersion.getExtension(); var namespace = extension.getNamespace(); - Optional upstreamExists = upstreamExists(extension); - if (upstreamExists.isEmpty()) { - throw new ScannerException("Failed to perform " + TYPE); - } else { - boolean upstreamDoesExists = upstreamExists.orElseThrow(); - if (!upstreamDoesExists) { + try { + boolean upstreamExists = upstreamExists(extension); + if (!upstreamExists) { return new Scanner.Invocation.Completed(Scanner.Result.clean()); } + } catch (RestClientException ex) { + if (config.isEnforced()) { + throw new ScannerException("Failed to perform " + TYPE, ex); + } else { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Failed to perform " + TYPE + " scan: " + ex.getMessage())); + } } var publishedWith = extVersion.getPublishedWith(); @@ -124,7 +128,7 @@ public boolean isAsync() { var threat = new Scanner.Threat( "vscode-gallery-namespace-conflict", "'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " + - "and the publishing user is not an owner of namespace '" + namespace.getName() + "'.", + "and the target namespace is not verified '" + namespace.getName() + "'.", "high"); return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); } @@ -134,7 +138,7 @@ public boolean isAsync() { * unreachable). It will return non-empty optional wrapped boolean only if it has definitive answer, whether * remote end have or does not have extension. */ - private Optional upstreamExists(Extension extension) { + private boolean upstreamExists(Extension extension) throws RestClientException { var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); var requestData = new ExtensionQueryParam( List.of( @@ -153,19 +157,13 @@ private Optional upstreamExists(Extension extension) { 0); var headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set(HttpHeaders.ACCEPT, "application/json;api-version=" + VSCodeIdService.API_VERSION); - try { - var result = restTemplate - .postForObject(requestUrl, new HttpEntity<>(requestData, headers), ExtensionQueryResult.class); - if (result != null && result.results() != null && !result.results().isEmpty()) { - var item = result.results().getFirst(); - if (item.extensions() != null && !item.extensions().isEmpty()) { - return Optional.of(Boolean.TRUE); - } - } - return Optional.of(Boolean.FALSE); - } catch (RestClientException e) { - return Optional.empty(); // ie upstream is down or whatever; we have no definite answer + headers.set(HttpHeaders.ACCEPT, "application/json;api-version=" + IVSCodeService.GALLERY_API_VERSION); + var result = restTemplate + .postForObject(requestUrl, new HttpEntity<>(requestData, headers), ExtensionQueryResult.class); + if (result != null && result.results() != null && !result.results().isEmpty()) { + var item = result.results().getFirst(); + return item.extensions() != null && !item.extensions().isEmpty(); } + return false; } } diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java index ca00cba07..56aedb1e0 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java @@ -81,8 +81,8 @@ class VSCodeGalleryExistenceCheckScannerTest { private final ExtensionQueryResult emptyResult = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); - private VSCodeGalleryExistenceCheckScanner newScanner() { - var config = new VSCodeGalleryExistenceCheckConfig(true, true, true, "http://irrelevant"); + private VSCodeGalleryExistenceCheckScanner newScanner(boolean enforced) { + var config = new VSCodeGalleryExistenceCheckConfig(true, true, enforced, "http://irrelevant"); return new VSCodeGalleryExistenceCheckScanner( config, restTemplate, @@ -110,12 +110,22 @@ private ExtensionVersion extensionVersion(UserData publisher) { } @Test - void startScan_isClean_whenUpstreamIsDown() { + void startScan_throws_whenEnforcedAndUpstreamIsDown() { var extVersion = extensionVersion(new UserData()); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); - assertThrows(ScannerException.class, () -> newScanner().startScan(new Scanner.Command(1L, "scan-1"))); + assertThrows(ScannerException.class, () -> newScanner(true).startScan(new Scanner.Command(1L, "scan-1"))); + } + + @Test + void startScan_isClean_whenNotEnforcedAndUpstreamIsDown() throws Exception { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); + + var invocation = (Scanner.Invocation.Completed) newScanner(false).startScan(new Scanner.Command(1L, "scan-1")); + assertTrue(invocation.result().isClean()); } @Test @@ -124,7 +134,7 @@ void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(emptyResult); - var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); verify(repositories, never()).isVerified(any(), any()); @@ -138,7 +148,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); - var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); assertEquals(1, invocation.result().getThreats().size()); @@ -152,7 +162,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); - var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); assertEquals(0, invocation.result().getThreats().size()); @@ -164,7 +174,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); - var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); verify(repositories, never()).isVerified(any(), any()); @@ -174,6 +184,6 @@ void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() void startScan_throws_whenExtensionVersionNotFound() { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null); - assertThrows(ScannerException.class, () -> newScanner().startScan(new Scanner.Command(1L, "scan-1"))); + assertThrows(ScannerException.class, () -> newScanner(true).startScan(new Scanner.Command(1L, "scan-1"))); } } From a05a5afa6dc46ecb46ad5bee6686babe458f0b10 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 12:33:32 +0200 Subject: [PATCH 07/17] Leftover --- .../openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java index 11dd9415f..570a8be89 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java @@ -93,7 +93,7 @@ public String getGalleryUrl() { public void validate() { if (enabled) { if (galleryUrl == null || galleryUrl.isEmpty()) { - throw new IllegalStateException("ovsx.scanning.gallery-ownership.gallery-url must be set"); + throw new IllegalStateException("ovsx.scanning.gallery-existence-check.gallery-url must be set"); } } } From f33b61c556d84cff87dd2f337ce948327d11d2ca Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 12:35:46 +0200 Subject: [PATCH 08/17] Fix javadoc --- .../openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java index 019df739f..b4f4e2877 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java @@ -134,9 +134,8 @@ public boolean isAsync() { } /** - * Method reaching upstream; if return Optional is empty, check is not definitive (ie. remote end is down or - * unreachable). It will return non-empty optional wrapped boolean only if it has definitive answer, whether - * remote end have or does not have extension. + * Method reaching upstream; if returns {@code true} or {@code false} only if check was performed and result was + * clear about it. In any other case method will throw. */ private boolean upstreamExists(Extension extension) throws RestClientException { var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); From ec01b068da46b972dcdb7f2b082c2f47b00df07d Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 12:40:37 +0200 Subject: [PATCH 09/17] Tidy up test --- .../VSCodeGalleryExistenceCheckScannerTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java index 56aedb1e0..69f020b74 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java @@ -53,9 +53,9 @@ class VSCodeGalleryExistenceCheckScannerTest { private ScannerRegistry scannerRegistry; /** - * Dummy result; irrelevant of content + * Dummy non-empty result; irrelevant of content */ - private final ExtensionQueryResult dummyResult = new ExtensionQueryResult( + private static final ExtensionQueryResult NON_EMPTY = new ExtensionQueryResult( List.of( new ExtensionQueryResult.ResultItem( List.of( @@ -76,9 +76,9 @@ class VSCodeGalleryExistenceCheckScannerTest { List.of()))); /** - * Dummy result; irrelevant of content + * Dummy empty result; irrelevant of content */ - private final ExtensionQueryResult emptyResult = new ExtensionQueryResult( + private static final ExtensionQueryResult EMPTY = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); private VSCodeGalleryExistenceCheckScanner newScanner(boolean enforced) { @@ -132,7 +132,7 @@ void startScan_isClean_whenNotEnforcedAndUpstreamIsDown() throws Exception { void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { var extVersion = extensionVersion(new UserData()); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(emptyResult); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(EMPTY); var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); @@ -145,7 +145,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); @@ -159,7 +159,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); @@ -172,7 +172,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception { var extVersion = extensionVersion(null); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(dummyResult); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); From 8df9a4e8a31e2b94b711562b2706290dff607e22 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 12:55:32 +0200 Subject: [PATCH 10/17] Tidy up messages; align them --- .../scanning/VSCodeGalleryExistenceCheckScanner.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java index b4f4e2877..711ca245f 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java @@ -42,7 +42,7 @@ @Component public class VSCodeGalleryExistenceCheckScanner implements Scanner { - public static final String TYPE = "vscode-gallery-ownership"; + public static final String TYPE = "gallery-existence-check"; private final VSCodeGalleryExistenceCheckConfig config; private final RestTemplate restTemplate; @@ -122,13 +122,14 @@ public boolean isAsync() { if (user != null && repositories.isVerified(namespace, user)) { return new Scanner.Invocation.Completed( Scanner.Result.clean( - "Extension exists on the VS Code Marketplace; namespace confirmed as verified.")); + "Extension '" + NamingUtil.toExtensionId(extension) + + "' exists on the VS Code Marketplace and target namespace is verified.")); } var threat = new Scanner.Threat( - "vscode-gallery-namespace-conflict", - "'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " + - "and the target namespace is not verified '" + namespace.getName() + "'.", + TYPE + "-conflict", + "Extension '" + NamingUtil.toExtensionId(extension) + "' exists on the VS Code Marketplace, " + + "but the target namespace is not verified.", "high"); return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); } From f4a25d76e8b9beb8f5b5309ba80df587135906f1 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 19 Aug 2026 17:28:39 +0200 Subject: [PATCH 11/17] Active extensions are skipped Do not check if extension is already active. --- .../VSCodeGalleryExistenceCheckConfig.java | 22 ++++++- .../VSCodeGalleryExistenceCheckScanner.java | 7 +++ ...SCodeGalleryExistenceCheckScannerTest.java | 59 ++++++++++++++++--- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java index 570a8be89..e1bab6bb2 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java @@ -48,6 +48,15 @@ public class VSCodeGalleryExistenceCheckConfig { @Value("${ovsx.scanning.gallery-existence-check.enforced:true}") private boolean enforced; + /** + * Is NS verification check needed to run on already existing and active extensions or not. + *

+ * Property: {@code ovsx.scanning.gallery-existence-check.checkActiveExtensions} + * Default: {@code false} + */ + @Value("${ovsx.scanning.gallery-existence-check.checkActiveExtensions:false}") + private boolean checkActiveExtensions; + /** * The upstream gallery API URL to perform the existence checks against. *

@@ -66,10 +75,17 @@ public VSCodeGalleryExistenceCheckConfig() { /** * For testing. */ - public VSCodeGalleryExistenceCheckConfig(boolean enabled, boolean required, boolean enforced, String galleryUrl) { + public VSCodeGalleryExistenceCheckConfig( + boolean enabled, + boolean required, + boolean enforced, + boolean checkActiveExtensions, + String galleryUrl + ) { this.enabled = enabled; this.required = required; this.enforced = enforced; + this.checkActiveExtensions = checkActiveExtensions; this.galleryUrl = galleryUrl; } @@ -85,6 +101,10 @@ public boolean isEnforced() { return enforced; } + public boolean isCheckActiveExtensions() { + return checkActiveExtensions; + } + public String getGalleryUrl() { return galleryUrl; } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java index 711ca245f..84c29a3ee 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java @@ -102,6 +102,13 @@ public boolean isAsync() { var extension = extVersion.getExtension(); var namespace = extension.getNamespace(); + Extension active = repositories.findActiveExtension(extension.getName(), namespace.getName()); + if (!config.isCheckActiveExtensions() && active != null) { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Extension '" + NamingUtil.toExtensionId(extension) + "' is already active.")); + } + try { boolean upstreamExists = upstreamExists(extension); if (!upstreamExists) { diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java index 69f020b74..2ad8d28d4 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java @@ -36,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -81,8 +82,13 @@ class VSCodeGalleryExistenceCheckScannerTest { private static final ExtensionQueryResult EMPTY = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); - private VSCodeGalleryExistenceCheckScanner newScanner(boolean enforced) { - var config = new VSCodeGalleryExistenceCheckConfig(true, true, enforced, "http://irrelevant"); + private VSCodeGalleryExistenceCheckScanner newScanner(boolean enforced, boolean checkActiveExtensions) { + var config = new VSCodeGalleryExistenceCheckConfig( + true, + true, + enforced, + checkActiveExtensions, + "http://irrelevant"); return new VSCodeGalleryExistenceCheckScanner( config, restTemplate, @@ -115,7 +121,7 @@ void startScan_throws_whenEnforcedAndUpstreamIsDown() { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); - assertThrows(ScannerException.class, () -> newScanner(true).startScan(new Scanner.Command(1L, "scan-1"))); + assertThrows(ScannerException.class, () -> newScanner(true, true).startScan(new Scanner.Command(1L, "scan-1"))); } @Test @@ -124,7 +130,8 @@ void startScan_isClean_whenNotEnforcedAndUpstreamIsDown() throws Exception { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); - var invocation = (Scanner.Invocation.Completed) newScanner(false).startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(false, true) + .startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); } @@ -134,7 +141,8 @@ void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(EMPTY); - var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); verify(repositories, never()).isVerified(any(), any()); @@ -148,7 +156,8 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); - var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); assertEquals(1, invocation.result().getThreats().size()); @@ -162,7 +171,8 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); - var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); assertEquals(0, invocation.result().getThreats().size()); @@ -174,16 +184,47 @@ void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); - var invocation = (Scanner.Invocation.Completed) newScanner(true).startScan(new Scanner.Command(1L, "scan-1")); + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); verify(repositories, never()).isVerified(any(), any()); } + @Test + void startScan_isClean_isActiveAndCheckActiveExtensions() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); + when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); + when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(restTemplate, atLeastOnce()).postForObject(anyString(), any(), any()); + } + + @Test + void startScan_isClean_isActiveAndNotCheckActiveExtensions() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, false) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(restTemplate, never()).postForObject(anyString(), any(), any()); + } + @Test void startScan_throws_whenExtensionVersionNotFound() { when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null); - assertThrows(ScannerException.class, () -> newScanner(true).startScan(new Scanner.Command(1L, "scan-1"))); + assertThrows(ScannerException.class, () -> newScanner(true, true).startScan(new Scanner.Command(1L, "scan-1"))); } } From 3c0bbaa49ded77a64ac9e64478d016df5795a43f Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Sun, 23 Aug 2026 21:39:00 +0200 Subject: [PATCH 12/17] refactor: rename VSCodeGalleryExistenceCheck to NamespaceOwnershipCheck The scanner's purpose is to guard against namespace-squatting - blocking publication into an unverified namespace that already exists in a referenced external gallery - not to check "gallery existence" in the abstract. The old name also baked a vendor (VS Code Marketplace) into the class identifiers even though the gallery URL is configurable. Rename to match what's already generic in the TYPE constant and config property namespace: - VSCodeGalleryExistenceCheckScanner -> NamespaceOwnershipCheckScanner - VSCodeGalleryExistenceCheckConfig -> NamespaceOwnershipCheckConfig - TYPE: gallery-existence-check -> namespace-ownership-check - properties: ovsx.scanning.gallery-existence-check.* -> ovsx.scanning.namespace-ownership-check.* Also updated javadoc, log/threat messages, and the internal upstreamExists() method to describe "the referenced gallery" instead of hardcoding "VS Code Marketplace" wording. Co-Authored-By: Claude Sonnet 5 --- ...ava => NamespaceOwnershipCheckConfig.java} | 40 +++++++++---------- ...va => NamespaceOwnershipCheckScanner.java} | 31 +++++++------- ...> NamespaceOwnershipCheckScannerTest.java} | 8 ++-- 3 files changed, 39 insertions(+), 40 deletions(-) rename server/src/main/java/org/eclipse/openvsx/scanning/{VSCodeGalleryExistenceCheckConfig.java => NamespaceOwnershipCheckConfig.java} (59%) rename server/src/main/java/org/eclipse/openvsx/scanning/{VSCodeGalleryExistenceCheckScanner.java => NamespaceOwnershipCheckScanner.java} (84%) rename server/src/test/java/org/eclipse/openvsx/scanning/{VSCodeGalleryExistenceCheckScannerTest.java => NamespaceOwnershipCheckScannerTest.java} (97%) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java similarity index 59% rename from server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java rename to server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java index e1bab6bb2..3b7419a26 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java @@ -17,65 +17,65 @@ import org.springframework.context.annotation.Configuration; /** - * Configuration for {@link VSCodeGalleryExistenceCheckScanner}, extracted, to simplify testing. + * Configuration for {@link NamespaceOwnershipCheckScanner}, extracted, to simplify testing. */ @Configuration -public class VSCodeGalleryExistenceCheckConfig { +public class NamespaceOwnershipCheckConfig { /** - * Is NS verification check for upstream existing extensions enabled or not. + * Is the namespace ownership check for extensions existing in the referenced gallery enabled or not. *

- * Property: {@code ovsx.scanning.gallery-existence-check.enabled} + * Property: {@code ovsx.scanning.namespace-ownership-check.enabled} * Default: {@code false} */ - @Value("${ovsx.scanning.gallery-existence-check.enabled:false}") + @Value("${ovsx.scanning.namespace-ownership-check.enabled:false}") private boolean enabled; /** - * Is NS verification check for upstream existing extensions required or not. + * Is the namespace ownership check required or not. *

- * Property: {@code ovsx.scanning.gallery-existence-check.required} + * Property: {@code ovsx.scanning.namespace-ownership-check.required} * Default: {@code true} */ - @Value("${ovsx.scanning.gallery-existence-check.required:true}") + @Value("${ovsx.scanning.namespace-ownership-check.required:true}") private boolean required; /** - * Is NS verification check for upstream existing extensions enforced or not. + * Is the namespace ownership check enforced or not. *

- * Property: {@code ovsx.scanning.gallery-existence-check.enforced} + * Property: {@code ovsx.scanning.namespace-ownership-check.enforced} * Default: {@code true} */ - @Value("${ovsx.scanning.gallery-existence-check.enforced:true}") + @Value("${ovsx.scanning.namespace-ownership-check.enforced:true}") private boolean enforced; /** - * Is NS verification check needed to run on already existing and active extensions or not. + * Is the namespace ownership check needed to run on already existing and active extensions or not. *

- * Property: {@code ovsx.scanning.gallery-existence-check.checkActiveExtensions} + * Property: {@code ovsx.scanning.namespace-ownership-check.checkActiveExtensions} * Default: {@code false} */ - @Value("${ovsx.scanning.gallery-existence-check.checkActiveExtensions:false}") + @Value("${ovsx.scanning.namespace-ownership-check.checkActiveExtensions:false}") private boolean checkActiveExtensions; /** - * The upstream gallery API URL to perform the existence checks against. + * The referenced gallery API URL to perform the namespace ownership checks against. *

- * Property: {@code ovsx.scanning.gallery-existence-check.gallery-url} + * Property: {@code ovsx.scanning.namespace-ownership-check.gallery-url} * Default: {@code ""} */ - @Value("${ovsx.scanning.gallery-existence-check.gallery-url:}") + @Value("${ovsx.scanning.namespace-ownership-check.gallery-url:}") private String galleryUrl; /** * Default constructor. */ - public VSCodeGalleryExistenceCheckConfig() { + public NamespaceOwnershipCheckConfig() { } /** * For testing. */ - public VSCodeGalleryExistenceCheckConfig( + public NamespaceOwnershipCheckConfig( boolean enabled, boolean required, boolean enforced, @@ -113,7 +113,7 @@ public String getGalleryUrl() { public void validate() { if (enabled) { if (galleryUrl == null || galleryUrl.isEmpty()) { - throw new IllegalStateException("ovsx.scanning.gallery-existence-check.gallery-url must be set"); + throw new IllegalStateException("ovsx.scanning.namespace-ownership-check.gallery-url must be set"); } } } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java similarity index 84% rename from server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java rename to server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java index 84c29a3ee..e5a958fcb 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java @@ -34,24 +34,23 @@ import org.eclipse.openvsx.util.UrlUtil; /** - * Scanner that blocks publishing to a namespace/extension identifier that already exists on the - * upstream VS Code Marketplace, unless the publishing NS is verified (has owner, not only - * contributors) namespace. Guards against namespace-squatting relative to the upstream - * gallery identity. + * Scanner that guards against namespace-squatting: it blocks publishing to a namespace/extension + * identifier that already exists in a referenced external gallery (by default the upstream VS Code + * Marketplace), unless the publishing namespace is verified (has an owner, not only contributors). */ @Component -public class VSCodeGalleryExistenceCheckScanner implements Scanner { +public class NamespaceOwnershipCheckScanner implements Scanner { - public static final String TYPE = "gallery-existence-check"; + public static final String TYPE = "namespace-ownership-check"; - private final VSCodeGalleryExistenceCheckConfig config; + private final NamespaceOwnershipCheckConfig config; private final RestTemplate restTemplate; private final RepositoryService repositories; private final EntityManager entityManager; private final ScannerRegistry scannerRegistry; - public VSCodeGalleryExistenceCheckScanner( - VSCodeGalleryExistenceCheckConfig config, + public NamespaceOwnershipCheckScanner( + NamespaceOwnershipCheckConfig config, RestTemplate restTemplate, RepositoryService repositories, EntityManager entityManager, @@ -110,8 +109,8 @@ public boolean isAsync() { } try { - boolean upstreamExists = upstreamExists(extension); - if (!upstreamExists) { + boolean existsInReferencedGallery = existsInReferencedGallery(extension); + if (!existsInReferencedGallery) { return new Scanner.Invocation.Completed(Scanner.Result.clean()); } } catch (RestClientException ex) { @@ -130,22 +129,22 @@ public boolean isAsync() { return new Scanner.Invocation.Completed( Scanner.Result.clean( "Extension '" + NamingUtil.toExtensionId(extension) - + "' exists on the VS Code Marketplace and target namespace is verified.")); + + "' exists in the referenced gallery and target namespace is verified.")); } var threat = new Scanner.Threat( TYPE + "-conflict", - "Extension '" + NamingUtil.toExtensionId(extension) + "' exists on the VS Code Marketplace, " + + "Extension '" + NamingUtil.toExtensionId(extension) + "' exists in the referenced gallery, " + "but the target namespace is not verified.", "high"); return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); } /** - * Method reaching upstream; if returns {@code true} or {@code false} only if check was performed and result was - * clear about it. In any other case method will throw. + * Queries the referenced gallery; returns {@code true} or {@code false} only if the check was + * performed and the result was clear about it. In any other case this method throws. */ - private boolean upstreamExists(Extension extension) throws RestClientException { + private boolean existsInReferencedGallery(Extension extension) throws RestClientException { var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); var requestData = new ExtensionQueryParam( List.of( diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java similarity index 97% rename from server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java rename to server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java index 2ad8d28d4..305eb072d 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/VSCodeGalleryExistenceCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java @@ -42,7 +42,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class VSCodeGalleryExistenceCheckScannerTest { +class NamespaceOwnershipCheckScannerTest { @Mock private RestTemplate restTemplate; @@ -82,14 +82,14 @@ class VSCodeGalleryExistenceCheckScannerTest { private static final ExtensionQueryResult EMPTY = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); - private VSCodeGalleryExistenceCheckScanner newScanner(boolean enforced, boolean checkActiveExtensions) { - var config = new VSCodeGalleryExistenceCheckConfig( + private NamespaceOwnershipCheckScanner newScanner(boolean enforced, boolean checkActiveExtensions) { + var config = new NamespaceOwnershipCheckConfig( true, true, enforced, checkActiveExtensions, "http://irrelevant"); - return new VSCodeGalleryExistenceCheckScanner( + return new NamespaceOwnershipCheckScanner( config, restTemplate, repositories, From 24aff15fe3b566410088b828f445a7d67ae0afe0 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Sun, 23 Aug 2026 23:06:56 +0200 Subject: [PATCH 13/17] feat: surface namespace ownership conflicts in the webui Adds a precise, server-computed signal for when an extension version can't be activated because its namespace already exists in a referenced external gallery and isn't verified (the NamespaceOwnershipCheckScanner threat), and surfaces it across the extension listing and settings views. Backend: - ExtensionJson: new `namespaceOwnershipConflict` field. - UserAPI: computed from the ExtensionThreat records for the latest scan of a version, wired into both /user/extensions ("My Extensions") and /user/extension/{ns}/{ext} (the namespace-member scoped endpoint). Also fixes /user/extensions never setting `verified` on the response. Frontend: - ExtensionCardListItem: "Namespace needs verification" status, shared by the "My Extensions" and namespace member extension lists. - ExtensionDetailView: explanatory notice plus a "Claim Namespace" action next to "View in Marketplace" - uses the deployment's configured `pageSettings.elements.claimNamespace` when available, falling back to the namespace access docs otherwise. - ExtensionStatusChips: "Namespace Not Verified" chip alongside the existing status chips. Co-Authored-By: Claude Sonnet 5 --- .../java/org/eclipse/openvsx/UserAPI.java | 59 ++++++++-- .../eclipse/openvsx/json/ExtensionJson.java | 18 +++ .../NamespaceOwnershipCheckConfig.java | 4 +- .../NamespaceOwnershipCheckScanner.java | 69 ++++++++---- .../java/org/eclipse/openvsx/UserAPITest.java | 102 +++++++++++++++++ .../NamespaceOwnershipCheckScannerTest.java | 106 +++++++++++++----- webui/CHANGELOG.md | 3 + .../extension/extension-card-list-item.tsx | 14 +++ .../extension/extension-detail-view.tsx | 48 +++++++- webui/src/extension-registry-types.ts | 3 + .../extension-card-list-item.spec.tsx | 96 ++++++++++++++++ .../extension/extension-detail-view.spec.tsx | 83 +++++++++++++- 12 files changed, 540 insertions(+), 65 deletions(-) create mode 100644 webui/test/unit/components/extension/extension-card-list-item.spec.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 76b478692..87887fd11 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -36,6 +36,7 @@ import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; +import org.eclipse.openvsx.entities.ExtensionScan; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersonalAccessTokenType; @@ -57,6 +58,7 @@ import org.eclipse.openvsx.json.UserJson; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.security.CodedAuthException; import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.storage.StorageUtilService; @@ -274,6 +276,7 @@ public List getOwnExtensions() { json.setPreview(latest.isPreview()); json.setActive(latest.getExtension().isActive()); json.setRemoved(latest.isExtensionRemoved()); + json.setVerified(isExtensionVerified(latest)); json.setFiles(fileUrls.get(latest.getId())); // Add scan/review status information @@ -284,6 +287,51 @@ public List getOwnExtensions() { .toList(); } + /** + * Whether {@code extVersion} counts as verified: published by a privileged user, or by a member of + * a namespace that has at least one owner. Mirrors {@code LocalRegistryService}'s own computation + * for the same {@code ExtensionJson.verified} field on the public endpoints. + */ + private boolean isExtensionVerified(ExtensionVersion extVersion) { + if (extVersion.getPublishedWith() == null) { + return false; + } + + var user = extVersion.getPublishedWith().getUser(); + if (UserData.Role.PRIVILEGED.equals(user.getRole())) { + return true; + } + + return repositories.isVerified(extVersion.getExtension().getNamespace(), user); + } + + /** + * Looks up the most recent scan recorded for {@code extVersion}, or {@code null} if none exists + * (scanning disabled, or the version predates the scanning feature). + */ + private ExtensionScan findLatestScan(ExtensionVersion extVersion) { + var ext = extVersion.getExtension(); + return scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + ext.getNamespace().getName(), + ext.getName(), + extVersion.getVersion(), + extVersion.getTargetPlatform()); + } + + /** + * Whether {@code scanResult} recorded a threat from the {@link NamespaceOwnershipCheckScanner}: + * the version's namespace already exists in a referenced external gallery and needs to be verified + * (claimed) before the version can be activated here. + */ + private boolean hasNamespaceOwnershipConflict(ExtensionScan scanResult) { + return scanResult != null + && repositories.findExtensionThreats(scanResult, NamespaceOwnershipCheckScanner.TYPE) + .stream() + .findAny() + .isPresent(); + } + /** * Add review/scan status information to the extension JSON. *

@@ -295,14 +343,8 @@ public List getOwnExtensions() { * */ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVersion) { - // Look up scan by extension metadata (namespace, name, version, platform) - var ext = extVersion.getExtension(); - var scanResult = scanRepository - .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( - ext.getNamespace().getName(), - ext.getName(), - extVersion.getVersion(), - extVersion.getTargetPlatform()); + var scanResult = findLatestScan(extVersion); + json.setNamespaceOwnershipConflict(hasNamespaceOwnershipConflict(scanResult)); if (Boolean.TRUE.equals(json.getActive())) { // Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual @@ -440,6 +482,7 @@ public ResponseEntity getOwnExtension( json.setAllTargetPlatformVersions(users.getVersionsWithDeletePermission(user, extension, isOwner)); json.setActive(extension.isActive()); json.setRemoved(latest.isExtensionRemoved()); + json.setNamespaceOwnershipConflict(hasNamespaceOwnershipConflict(findLatestScan(latest))); } else { var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName); throw new ErrorResultException(error, HttpStatus.NOT_FOUND); diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java index 10d7b73fa..3bb3ae4df 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java @@ -111,6 +111,14 @@ public static ExtensionJson error(String message) { @NotNull private Boolean verified; + /** + * True when this version's latest scan recorded a namespace-ownership-check threat, i.e. the + * namespace it was published to already exists in a referenced external gallery and is not + * verified. Internal signal for the webui, not part of the documented public API. + */ + @Schema(hidden = true) + private Boolean namespaceOwnershipConflict; + /** * @deprecated */ @@ -347,6 +355,14 @@ public void setVerified(Boolean verified) { this.verified = verified; } + public Boolean getNamespaceOwnershipConflict() { + return namespaceOwnershipConflict; + } + + public void setNamespaceOwnershipConflict(Boolean namespaceOwnershipConflict) { + this.namespaceOwnershipConflict = namespaceOwnershipConflict; + } + public Map getAllVersions() { return allVersions; } @@ -642,6 +658,7 @@ public boolean equals(Object o) { && Objects.equals(reviewStatus, that.reviewStatus) && Objects.equals(reviewMessage, that.reviewMessage) && Objects.equals(verified, that.verified) + && Objects.equals(namespaceOwnershipConflict, that.namespaceOwnershipConflict) && Objects.equals(allVersions, that.allVersions) && Objects.equals(allVersionsUrl, that.allVersionsUrl) && Objects.equals(averageRating, that.averageRating) @@ -692,6 +709,7 @@ public int hashCode() { reviewStatus, reviewMessage, verified, + namespaceOwnershipConflict, allVersions, allVersionsUrl, averageRating, diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java index 3b7419a26..f69ef5884 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java @@ -51,10 +51,10 @@ public class NamespaceOwnershipCheckConfig { /** * Is the namespace ownership check needed to run on already existing and active extensions or not. *

- * Property: {@code ovsx.scanning.namespace-ownership-check.checkActiveExtensions} + * Property: {@code ovsx.scanning.namespace-ownership-check.check-active-extensions} * Default: {@code false} */ - @Value("${ovsx.scanning.namespace-ownership-check.checkActiveExtensions:false}") + @Value("${ovsx.scanning.namespace-ownership-check.check-active-extensions:false}") private boolean checkActiveExtensions; /** diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java index e5a958fcb..27d72401d 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java @@ -13,6 +13,7 @@ package org.eclipse.openvsx.scanning; import java.util.List; +import java.util.Objects; import jakarta.annotation.PostConstruct; import jakarta.persistence.EntityManager; @@ -34,14 +35,24 @@ import org.eclipse.openvsx.util.UrlUtil; /** - * Scanner that guards against namespace-squatting: it blocks publishing to a namespace/extension - * identifier that already exists in a referenced external gallery (by default the upstream VS Code - * Marketplace), unless the publishing namespace is verified (has an owner, not only contributors). + * Scanner that guards against namespace-squatting: it blocks publishing into a namespace that is + * already claimed by some publisher in a referenced external gallery (by default the upstream VS + * Code Marketplace), unless the publishing namespace is verified (has an owner, not only + * contributors). The check is namespace-wide - it doesn't matter which extension(s) the upstream + * publisher offers, only that the namespace name itself is already taken there. */ @Component public class NamespaceOwnershipCheckScanner implements Scanner { - public static final String TYPE = "namespace-ownership-check"; + public static final String TYPE = "NAMESPACE_OWNERSHIP_CHECK"; + + /** + * The VS Code Gallery API has no dedicated "publisher exists" filter, so an existence check has to + * search by namespace name as free text and inspect the results for an exact publisher match. A + * generous page size keeps that match from being missed when the namespace name also turns up + * loosely-related, more "relevant" extensions from other publishers. + */ + private static final int NAMESPACE_SEARCH_PAGE_SIZE = 100; private final NamespaceOwnershipCheckConfig config; private final RestTemplate restTemplate; @@ -109,8 +120,8 @@ public boolean isAsync() { } try { - boolean existsInReferencedGallery = existsInReferencedGallery(extension); - if (!existsInReferencedGallery) { + boolean namespaceExists = namespaceExistsInReferencedGallery(namespace.getName()); + if (!namespaceExists) { return new Scanner.Invocation.Completed(Scanner.Result.clean()); } } catch (RestClientException ex) { @@ -128,23 +139,32 @@ public boolean isAsync() { if (user != null && repositories.isVerified(namespace, user)) { return new Scanner.Invocation.Completed( Scanner.Result.clean( - "Extension '" + NamingUtil.toExtensionId(extension) - + "' exists in the referenced gallery and target namespace is verified.")); + "Namespace '" + namespace.getName() + + "' exists in the referenced gallery and is verified.")); } var threat = new Scanner.Threat( TYPE + "-conflict", - "Extension '" + NamingUtil.toExtensionId(extension) + "' exists in the referenced gallery, " + - "but the target namespace is not verified.", + "Namespace '" + namespace.getName() + "' exists in the referenced gallery, " + + "but is not verified.", "high"); return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); } /** - * Queries the referenced gallery; returns {@code true} or {@code false} only if the check was - * performed and the result was clear about it. In any other case this method throws. + * Checks whether {@code namespaceName} is already claimed by some publisher in the referenced + * gallery, regardless of which extension(s) that publisher offers there - the goal is to catch + * namespace-squatting, not to match a specific extension identifier. + *

+ * The VS Code Gallery API has no filter for "publisher exists", so this searches by the namespace + * name as free text and then checks the results for an extension whose publisher name matches + * {@code namespaceName} exactly (case-insensitively, as VS Code Marketplace publisher ids are + * themselves case-insensitive) - a hit that merely mentions the name elsewhere doesn't count. + *

+ * Returns {@code true} or {@code false} only if the check was performed and the result was clear + * about it. In any other case this method throws. */ - private boolean existsInReferencedGallery(Extension extension) throws RestClientException { + private boolean namespaceExistsInReferencedGallery(String namespaceName) throws RestClientException { var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); var requestData = new ExtensionQueryParam( List.of( @@ -154,10 +174,10 @@ private boolean existsInReferencedGallery(Extension extension) throws RestClient ExtensionQueryParam.Criterion.FILTER_TARGET, "Microsoft.VisualStudio.Code"), new ExtensionQueryParam.Criterion( - ExtensionQueryParam.Criterion.FILTER_EXTENSION_NAME, - NamingUtil.toExtensionId(extension))), - 1, + ExtensionQueryParam.Criterion.FILTER_SEARCH_TEXT, + namespaceName)), 1, + NAMESPACE_SEARCH_PAGE_SIZE, 0, 0)), 0); @@ -166,10 +186,19 @@ private boolean existsInReferencedGallery(Extension extension) throws RestClient headers.set(HttpHeaders.ACCEPT, "application/json;api-version=" + IVSCodeService.GALLERY_API_VERSION); var result = restTemplate .postForObject(requestUrl, new HttpEntity<>(requestData, headers), ExtensionQueryResult.class); - if (result != null && result.results() != null && !result.results().isEmpty()) { - var item = result.results().getFirst(); - return item.extensions() != null && !item.extensions().isEmpty(); + if (result == null || result.results() == null || result.results().isEmpty()) { + return false; } - return false; + + var extensions = result.results().getFirst().extensions(); + if (extensions == null) { + return false; + } + + return extensions.stream() + .map(ExtensionQueryResult.Extension::publisher) + .filter(Objects::nonNull) + .map(ExtensionQueryResult.Publisher::publisherName) + .anyMatch(namespaceName::equalsIgnoreCase); } } diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 064440790..61d5b8615 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -53,6 +53,7 @@ import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -335,6 +336,60 @@ void testOwnExtensionNotLoggedIn() throws Exception { .andExpect(status().isForbidden()); } + @Test + void testOwnExtensionsVerifiedAndNamespaceOwnershipConflict() throws Exception { + // An extension kept inactive because its namespace conflicts with one already claimed in a + // referenced external gallery: 'verified' is false (no owner yet) and + // 'namespaceOwnershipConflict' reports the specific reason, so the webui can point the user at + // verifying/claiming the namespace instead of a generic "under review" message. + var userData = mockUserData(); + var versions = mockExtension(userData, 1, 0, 0); + var latest = versions.getLast(); + var extension = latest.getExtension(); + extension.setActive(false); + var namespace = extension.getNamespace(); + + var token = new PersonalAccessToken(); + token.setUser(userData); + latest.setPublishedWith(token); + + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setUser(userData); + membership.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + Mockito.when(repositories.findMemberships(userData)).thenReturn(Streamable.of(membership)); + Mockito.when(repositories.isVerified(namespace, userData)).thenReturn(false); + + var scan = new ExtensionScan(); + scan.setStatus(ScanStatus.QUARANTINED); + Mockito.when( + scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + namespace.getName(), + extension.getName(), + latest.getVersion(), + TargetPlatform.NAME_UNIVERSAL)) + .thenReturn(scan); + Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) + .thenReturn( + Streamable.of( + ExtensionThreat.create( + null, + null, + null, + NamespaceOwnershipCheckScanner.TYPE, + NamespaceOwnershipCheckScanner.TYPE + "-conflict", + "Namespace 'foobar' exists in the referenced gallery, but is not verified.", + "high"))); + + mockMvc.perform( + get("/user/extensions") + .with(user("test_user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].verified").value(false)) + .andExpect(jsonPath("$[0].namespaceOwnershipConflict").value(true)); + } + @Test void testGetOwnExtensionAsNamespaceOwner() throws Exception { var userData = mockUserData(); @@ -436,6 +491,53 @@ void testGetOwnExtensionAsNamespaceMember() throws Exception { .andExpect(jsonPath("$.allTargetPlatformVersions[1].canDelete").value(true)); } + @Test + void testGetOwnExtensionNamespaceOwnershipConflict() throws Exception { + var userData = mockUserData(); + var versions = mockExtension(userData, 2, 0, 0); + var latest = versions.getLast(); + latest.setId(42L); + var extension = latest.getExtension(); + Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(true); + Mockito.when(repositories.findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false))) + .thenReturn(latest); + Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension)).thenReturn(List.of()); + Mockito.when( + storageUtil.getFileUrls( + Mockito.anyCollection(), + Mockito.anyString(), + Mockito.any(String[].class))) + .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>())); + + var scan = new ExtensionScan(); + scan.setStatus(ScanStatus.QUARANTINED); + Mockito.when( + scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + "foobar", + "baz", + latest.getVersion(), + TargetPlatform.NAME_UNIVERSAL)) + .thenReturn(scan); + Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) + .thenReturn( + Streamable.of( + ExtensionThreat.create( + null, + null, + null, + NamespaceOwnershipCheckScanner.TYPE, + NamespaceOwnershipCheckScanner.TYPE + "-conflict", + "Namespace 'foobar' exists in the referenced gallery, but is not verified.", + "high"))); + + mockMvc.perform( + get("/user/extension/{namespace}/{extension}", "foobar", "baz") + .with(user("test_user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.namespaceOwnershipConflict").value(true)); + } + @Test void testGetOwnExtensionNotMember() throws Exception { var userData = mockUserData(); diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java index 305eb072d..4613a2e2d 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java @@ -54,34 +54,52 @@ class NamespaceOwnershipCheckScannerTest { private ScannerRegistry scannerRegistry; /** - * Dummy non-empty result; irrelevant of content + * A search result containing an extension published under the "acme" namespace - the exact + * namespace under test - simulating that the namespace is already claimed upstream. */ - private static final ExtensionQueryResult NON_EMPTY = new ExtensionQueryResult( - List.of( - new ExtensionQueryResult.ResultItem( - List.of( - new ExtensionQueryResult.Extension( - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null)), - List.of()))); + private static final ExtensionQueryResult NAMESPACE_MATCH = searchResult(extensionOf("acme")); /** - * Dummy empty result; irrelevant of content + * A search result containing an extension published under "ACME" (different casing) - upstream + * publisher ids are case-insensitive, so this should still count as a match for "acme". + */ + private static final ExtensionQueryResult NAMESPACE_MATCH_DIFFERENT_CASE = searchResult(extensionOf("ACME")); + + /** + * A search result that only contains extensions from other publishers - simulating a loose, + * merely-relevant search hit that must NOT be mistaken for the "acme" namespace existing. + */ + private static final ExtensionQueryResult UNRELATED_PUBLISHERS = searchResult(extensionOf("acme-tools")); + + /** + * No search results at all. */ private static final ExtensionQueryResult EMPTY = new ExtensionQueryResult( List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); + private static ExtensionQueryResult searchResult(ExtensionQueryResult.Extension... extensions) { + return new ExtensionQueryResult( + List.of(new ExtensionQueryResult.ResultItem(List.of(extensions), List.of()))); + } + + private static ExtensionQueryResult.Extension extensionOf(String publisherName) { + var publisher = new ExtensionQueryResult.Publisher(null, null, publisherName, null, null); + return new ExtensionQueryResult.Extension( + null, + null, + null, + null, + publisher, + null, + null, + null, + null, + null, + null, + null, + null); + } + private NamespaceOwnershipCheckScanner newScanner(boolean enforced, boolean checkActiveExtensions) { var config = new NamespaceOwnershipCheckConfig( true, @@ -136,7 +154,7 @@ void startScan_isClean_whenNotEnforcedAndUpstreamIsDown() throws Exception { } @Test - void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { + void startScan_isClean_whenNamespaceDoesNotExistUpstream() throws Exception { var extVersion = extensionVersion(new UserData()); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(EMPTY); @@ -149,11 +167,26 @@ void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception { } @Test - void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws Exception { + void startScan_isClean_whenSearchHitsAreFromUnrelatedPublishers() throws Exception { + // The upstream search is loose (free text), so it may return extensions that merely mention + // the namespace name without actually being published under it - those must not count. + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(UNRELATED_PUBLISHERS); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(repositories, never()).isVerified(any(), any()); + } + + @Test + void startScan_raisesThreat_whenNamespaceExistsUpstreamAndIsNotVerified() throws Exception { var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) @@ -164,11 +197,11 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws } @Test - void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Exception { + void startScan_isClean_whenNamespaceExistsUpstreamAndIsVerified() throws Exception { var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) @@ -178,11 +211,28 @@ void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Ex assertEquals(0, invocation.result().getThreats().size()); } + @Test + void startScan_raisesThreat_whenNamespaceExistsUpstreamWithDifferentCasing() throws Exception { + // Upstream publisher ids are case-insensitive, so "ACME" must still be recognized as a match + // for the "acme" namespace being published to. + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH_DIFFERENT_CASE); + when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + assertEquals(1, invocation.result().getThreats().size()); + } + @Test void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception { var extVersion = extensionVersion(null); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) .startScan(new Scanner.Command(1L, "scan-1")); @@ -196,7 +246,7 @@ void startScan_isClean_isActiveAndCheckActiveExtensions() throws Exception { var user = new UserData(); var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); - when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NON_EMPTY); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index e6d31a6e1..c1beddfa9 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -7,6 +7,9 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added - Add a "Data Consistency" page to the admin dashboard (#1622): a live overview of every registered consistency check's finding count, with actions to refresh it and to fix findings one at a time or all at once +- Show a "Namespace needs verification" hint on an extension card when it can't be activated because its namespace already exists in a referenced external gallery and hasn't been verified, in both the "My Extensions" and namespace member extension lists +- Add a "Claim Namespace" action to the extension settings page, next to "View in Marketplace", when the extension has a namespace ownership conflict: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured +- Show a "Namespace Not Verified" status chip and an explanatory notice on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed before it can be activated ### Changed diff --git a/webui/src/components/extension/extension-card-list-item.tsx b/webui/src/components/extension/extension-card-list-item.tsx index bd907de3d..2afc6fc1a 100644 --- a/webui/src/components/extension/extension-card-list-item.tsx +++ b/webui/src/components/extension/extension-card-list-item.tsx @@ -58,6 +58,20 @@ export const ExtensionCardListItem: FunctionComponent + + Namespace needs verification + + + This namespace already exists on a referenced gallery and needs to be verified before this + extension can be activated. + + + ); + } + if (extension.reviewStatus === 'under_review') { return ( diff --git a/webui/src/components/extension/extension-detail-view.tsx b/webui/src/components/extension/extension-detail-view.tsx index e79bff62d..9718ee2ff 100644 --- a/webui/src/components/extension/extension-detail-view.tsx +++ b/webui/src/components/extension/extension-detail-view.tsx @@ -11,9 +11,11 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FunctionComponent, ReactNode, useEffect, useState } from 'react'; +import { FunctionComponent, ReactNode, useContext, useEffect, useState } from 'react'; import { Box, Button, Typography } from '@mui/material'; import { alpha, styled } from '@mui/material/styles'; +import WarningIcon from '@mui/icons-material/Warning'; +import { MainContext } from '../../context'; import { Extension, VERSION_ALIASES, VersionTargetPlatforms } from '../../extension-registry-types'; import { ExtensionHeader } from './extension-header'; import { ExtensionVersionTable } from './extension-version-table'; @@ -57,9 +59,28 @@ const DangerRow = styled(Box)({ padding: '1rem 1.25rem' }); +// `claimNamespace` renders as a Link (its target action is pluggable, e.g. an external issue +// template), styled here to sit as a button among the other actions in the Stack below. +const claimNamespaceButtonStyle = { + display: 'inline-flex', + alignItems: 'center', + px: 2, + py: '5px', + border: '1px solid', + borderColor: 'warning.main', + borderRadius: 1, + fontWeight: 500, + fontSize: '0.875rem', + lineHeight: 1.75, + textTransform: 'uppercase', + '&:hover': { textDecoration: 'none' } +}; + export const ExtensionDetailView: FunctionComponent = props => { const { extension, actions, onRemoveVersion, onVersionDeleted, onPurgeVersion } = props; const canPurge = !!onPurgeVersion; + const { pageSettings } = useContext(MainContext); + const ClaimNamespace = pageSettings.elements.claimNamespace; const [page, setPage] = useState(0); const [deleteDialogVersion, setDeleteDialogVersion] = useState(null); @@ -86,6 +107,31 @@ export const ExtensionDetailView: FunctionComponent = return ( + {extension.namespaceOwnershipConflict && ( + + + + This namespace already exists in a referenced gallery and needs to be claimed (verified) + before this extension can be activated. + + {ClaimNamespace ? ( + + ) : ( + // Fallback for a deployment that hasn't configured `elements.claimNamespace`: + // point at the generic namespace-access docs instead of showing nothing. + + )} + + )} General diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6f8d8608c..c03a7bcec 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -82,6 +82,9 @@ export interface Extension { removed?: boolean; reviewStatus?: 'published' | 'under_review' | 'rejected'; reviewMessage?: string; + // True when this version's latest scan found that its namespace already exists in a referenced + // external gallery and is not verified - the namespace needs to be verified/claimed to activate it. + namespaceOwnershipConflict?: boolean; averageRating?: number; downloadCount: number; diff --git a/webui/test/unit/components/extension/extension-card-list-item.spec.tsx b/webui/test/unit/components/extension/extension-card-list-item.spec.tsx new file mode 100644 index 000000000..26f9e0122 --- /dev/null +++ b/webui/test/unit/components/extension/extension-card-list-item.spec.tsx @@ -0,0 +1,96 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../support/test-providers'; +import { ExtensionCardListItem } from '../../../../src/components/extension/extension-card-list-item'; +import { Extension } from '../../../../src/extension-registry-types'; + +function buildExtension(overrides: Partial = {}): Extension { + return { + namespaceUrl: 'https://example.test/api/foo', + reviewsUrl: 'https://example.test/api/foo/bar/reviews', + files: {}, + name: 'bar', + namespace: 'foo', + version: '1.0.0', + targetPlatform: 'universal', + publishedBy: { loginName: 'someone', tokensUrl: '', createTokenUrl: '' }, + verified: true, + allVersions: {}, + downloadCount: 0, + reviewCount: 0, + versionAlias: [], + timestamp: '2026-01-01T00:00:00Z', + namespaceDisplayName: 'foo', + galleryColor: '', + galleryTheme: '', + downloads: {}, + deprecated: false, + downloadable: true, + ...overrides + }; +} + +describe('ExtensionCardListItem', () => { + it('shows a namespace-verification hint when namespaceOwnershipConflict is set', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Namespace needs verification')).toBeInTheDocument(); + expect(screen.queryByText('Deactivated')).not.toBeInTheDocument(); + }); + + it('prioritizes the namespace-verification hint over the generic review status', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Namespace needs verification')).toBeInTheDocument(); + expect(screen.queryByText('Under review')).not.toBeInTheDocument(); + }); + + it('still shows Deleted for a removed extension even with a namespace ownership conflict', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.queryByText('Namespace needs verification')).not.toBeInTheDocument(); + }); + + it('falls back to Deactivated when there is no namespace ownership conflict', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Deactivated')).toBeInTheDocument(); + expect(screen.queryByText('Namespace needs verification')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/test/unit/components/extension/extension-detail-view.spec.tsx b/webui/test/unit/components/extension/extension-detail-view.spec.tsx index f2f99ef07..fca39e7f3 100644 --- a/webui/test/unit/components/extension/extension-detail-view.spec.tsx +++ b/webui/test/unit/components/extension/extension-detail-view.spec.tsx @@ -6,11 +6,12 @@ * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at - * https://www.eclipse.org/legal/epl-2.0. + * https://www.eclipse.org/legal/epl-2.0 * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ +import { FunctionComponent } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../support/test-providers'; @@ -26,7 +27,7 @@ const version = (overrides: Partial = {}): VersionTarget ...overrides }); -const extension = (versions: VersionTargetPlatforms[]): Extension => +const extension = (versions: VersionTargetPlatforms[], overrides: Partial = {}): Extension => ({ name: 'bar', namespace: 'foo', @@ -37,17 +38,22 @@ const extension = (versions: VersionTargetPlatforms[]): Extension => reviewCount: 0, deprecated: false, active: true, - allTargetPlatformVersions: versions + allTargetPlatformVersions: versions, + ...overrides }) as unknown as Extension; -function renderDetail(versions: VersionTargetPlatforms[], onPurgeVersion?: () => Promise) { +function renderDetail( + versions: VersionTargetPlatforms[], + onPurgeVersion?: () => Promise, + overrides: { extension?: Partial; pageSettings?: Partial } = {} +) { const service = { getExtensionIcon: vi.fn().mockResolvedValue(null), getTrustedPublishingStatus: vi.fn().mockResolvedValue({ enabled: false, allowed: false }) } as unknown as ExtensionRegistryService; renderWithProviders( mainContext: { service, user: testUser, - pageSettings: { urls: { extensionDefaultIcon: '/icon.png' } } as PageSettings + pageSettings: { + urls: { extensionDefaultIcon: '/icon.png' }, + elements: {}, + ...overrides.pageSettings + } as PageSettings } } ); @@ -66,6 +76,13 @@ function renderDetail(versions: VersionTargetPlatforms[], onPurgeVersion?: () => const dangerButton = (name: string) => screen.getByRole('button', { name }); const noDangerButton = (name: string) => screen.queryByRole('button', { name }); +// Stands in for a deployment's configured `pageSettings.elements.claimNamespace`: this component's +// own logic is only responsible for passing `extension`/`sx` through, not for what a given +// implementation renders. +const ClaimNamespaceStub: FunctionComponent<{ extension: Extension }> = ({ extension: ext }) => ( + Claim {ext.namespace} +); + describe('ExtensionDetailView', () => { it('keeps the purge affordances out of the way without a purge handler', () => { renderDetail([version()]); @@ -106,4 +123,58 @@ describe('ExtensionDetailView', () => { expect(screen.queryByText('Danger Zone')).not.toBeInTheDocument(); }); + + describe('namespace ownership conflict', () => { + it('renders the configured claimNamespace element when there is a conflict', () => { + renderDetail([], undefined, { + extension: { active: false, namespaceOwnershipConflict: true }, + pageSettings: { elements: { claimNamespace: ClaimNamespaceStub } } + }); + + expect(screen.getByText('Claim foo')).toBeInTheDocument(); + }); + + it('does not render claimNamespace without a namespace ownership conflict', () => { + renderDetail([], undefined, { + extension: { active: true }, + pageSettings: { elements: { claimNamespace: ClaimNamespaceStub } } + }); + + expect(screen.queryByText('Claim foo')).not.toBeInTheDocument(); + }); + + it('falls back to a generic namespace-access link when no claimNamespace element is configured', () => { + renderDetail([], undefined, { + extension: { active: false, namespaceOwnershipConflict: true }, + pageSettings: { + elements: {}, + urls: { namespaceAccessInfo: 'https://example.test/namespace-access' } as PageSettings['urls'] + } + }); + + expect(screen.queryByText('Claim foo')).not.toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'Claim Namespace' }); + expect(link).toHaveAttribute('href', 'https://example.test/namespace-access'); + }); + + it('explains that the namespace needs to be claimed before the extension can be activated', () => { + renderDetail([], undefined, { + extension: { active: false, namespaceOwnershipConflict: true }, + pageSettings: { elements: { claimNamespace: ClaimNamespaceStub } } + }); + + expect( + screen.getByText(/needs to be claimed \(verified\) before this extension can be activated/) + ).toBeInTheDocument(); + }); + + it('does not show the namespace ownership explanation without a conflict', () => { + renderDetail([], undefined, { + extension: { active: true }, + pageSettings: { elements: { claimNamespace: ClaimNamespaceStub } } + }); + + expect(screen.queryByText(/needs to be claimed/)).not.toBeInTheDocument(); + }); + }); }); From d0a9a47f6597bd7e8a3f83bc79ef111dca8e2b49 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Sun, 23 Aug 2026 23:50:41 +0200 Subject: [PATCH 14/17] refactor: extract isVerifiedPublisher to fix and deduplicate the privileged-user check NamespaceOwnershipCheckScanner's namespace-verification check never included the privileged-user bypass used elsewhere (LocalRegistryService, and the new UserAPI code from the previous commit), so a privileged publisher could be wrongly flagged with a namespace-ownership conflict. Rather than duplicating the fix a third time, extract the shared logic: - UserData.isPrivileged(): small convenience for the role check. - RepositoryService.isVerifiedPublisher(Namespace, UserData) and an ExtensionVersion-taking overload: privileged user bypasses per-namespace verification entirely, otherwise defers to the existing isVerified(Namespace, UserData). Updated all three call sites to use it: - NamespaceOwnershipCheckScanner.startScan() - LocalRegistryService: removed the now-redundant single-arg private isVerified(ExtensionVersion) wrapper (one call site); the membership-map batch variant keeps its own logic but reuses UserData.isPrivileged() for the role check - UserAPI.getOwnExtensions(): removed the private isExtensionVerified() wrapper added in the previous commit Since RepositoryService is mocked (not spied) in unit tests, the privileged short-circuit that used to run as real caller code now lives behind the mock boundary. Fixed the resulting stale stubs/assertions in NamespaceOwnershipCheckScannerTest, RegistryAPITest (including testPublishRestrictedPrivileged, which relied on the check running for real), and RepositoryServiceSmokeTest's method-coverage list. RelevanceService.isVerified(ExtensionVersion) has the same missing- privileged-bypass gap but is left untouched here - fixing it would change search-relevance scoring, out of scope for this change. Co-Authored-By: Claude Sonnet 5 --- .../eclipse/openvsx/LocalRegistryService.java | 18 ++------------- .../java/org/eclipse/openvsx/UserAPI.java | 20 +---------------- .../eclipse/openvsx/entities/UserData.java | 8 +++++++ .../repositories/RepositoryService.java | 22 +++++++++++++++++++ .../NamespaceOwnershipCheckScanner.java | 2 +- .../org/eclipse/openvsx/RegistryAPITest.java | 12 ++++++---- .../java/org/eclipse/openvsx/UserAPITest.java | 2 +- .../RepositoryServiceSmokeTest.java | 2 ++ .../NamespaceOwnershipCheckScannerTest.java | 14 ++++++------ 9 files changed, 52 insertions(+), 48 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index c2f9351ab..5abbfd514 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -1069,7 +1069,7 @@ public ExtensionJson toExtensionVersionJson( } json.setVersionAlias(versionAlias); - json.setVerified(isVerified(extVersion)); + json.setVerified(repositories.isVerifiedPublisher(extVersion)); json.setReviewCount(Optional.ofNullable(extension.getReviewCount()).orElse(0L)); var serverUrl = UrlUtil.getBaseUrl(); json.setNamespaceUrl(createApiUrl(serverUrl, "api", json.getNamespace())); @@ -1339,20 +1339,6 @@ private ExtensionReplacementJson toReplacementJson( return json; } - private boolean isVerified(ExtensionVersion extVersion) { - if (extVersion.getPublishedWith() == null) { - return false; - } - - var user = extVersion.getPublishedWith().getUser(); - if (UserData.Role.PRIVILEGED.equals(user.getRole())) { - return true; - } - - var namespace = extVersion.getExtension().getNamespace(); - return repositories.isVerified(namespace, user); - } - private boolean isVerified( ExtensionVersion extVersion, Map> membershipsByNamespaceId @@ -1362,7 +1348,7 @@ private boolean isVerified( } var user = extVersion.getPublishedWith().getUser(); - if (UserData.Role.PRIVILEGED.equals(user.getRole())) { + if (user.isPrivileged()) { return true; } diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 87887fd11..f73423122 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -276,7 +276,7 @@ public List getOwnExtensions() { json.setPreview(latest.isPreview()); json.setActive(latest.getExtension().isActive()); json.setRemoved(latest.isExtensionRemoved()); - json.setVerified(isExtensionVerified(latest)); + json.setVerified(repositories.isVerifiedPublisher(latest)); json.setFiles(fileUrls.get(latest.getId())); // Add scan/review status information @@ -287,24 +287,6 @@ public List getOwnExtensions() { .toList(); } - /** - * Whether {@code extVersion} counts as verified: published by a privileged user, or by a member of - * a namespace that has at least one owner. Mirrors {@code LocalRegistryService}'s own computation - * for the same {@code ExtensionJson.verified} field on the public endpoints. - */ - private boolean isExtensionVerified(ExtensionVersion extVersion) { - if (extVersion.getPublishedWith() == null) { - return false; - } - - var user = extVersion.getPublishedWith().getUser(); - if (UserData.Role.PRIVILEGED.equals(user.getRole())) { - return true; - } - - return repositories.isVerified(extVersion.getExtension().getNamespace(), user); - } - /** * Looks up the most recent scan recorded for {@code extVersion}, or {@code null} if none exists * (scanning disabled, or the version predates the scanning feature). diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java index ebf1093eb..17fe9b229 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java @@ -109,6 +109,14 @@ public Role getRole() { return role; } + /** + * Whether this user bypasses per-namespace verification entirely, e.g. in + * {@link org.eclipse.openvsx.repositories.RepositoryService#isVerifiedPublisher}. + */ + public boolean isPrivileged() { + return Role.PRIVILEGED.equals(role); + } + public @Nullable String getRoleAsString() { return Optional.ofNullable(this.getRole()).map(Role::toString).orElse(null); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 36ac7b3a0..fa35939e1 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -498,6 +498,28 @@ public boolean isVerified(Namespace namespace, UserData user) { return membershipJooqRepo.isVerified(namespace, user); } + /** + * Whether {@code user} counts as a verified publisher for {@code namespace}: a privileged user + * bypasses per-namespace verification entirely; otherwise this defers to + * {@link #isVerified(Namespace, UserData)} (member of a namespace with at least one owner). + */ + public boolean isVerifiedPublisher(Namespace namespace, UserData user) { + return user.isPrivileged() || isVerified(namespace, user); + } + + /** + * Whether the version's publisher counts as verified, per {@link #isVerifiedPublisher(Namespace, UserData)}. + * {@code false} when the version records no publisher. + */ + public boolean isVerifiedPublisher(ExtensionVersion extVersion) { + var publishedWith = extVersion.getPublishedWith(); + if (publishedWith == null) { + return false; + } + + return isVerifiedPublisher(extVersion.getExtension().getNamespace(), publishedWith.getUser()); + } + public Streamable findMemberships(Namespace namespace, String role) { return membershipRepo.findByNamespaceAndRoleIgnoreCase(namespace, role); } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java index 27d72401d..65179060c 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java @@ -136,7 +136,7 @@ public boolean isAsync() { var publishedWith = extVersion.getPublishedWith(); var user = publishedWith != null ? publishedWith.getUser() : null; - if (user != null && repositories.isVerified(namespace, user)) { + if (user != null && repositories.isVerifiedPublisher(namespace, user)) { return new Scanner.Invocation.Completed( Scanner.Result.clean( "Namespace '" + namespace.getName() diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 274cf2df8..b1a0139e3 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -3393,7 +3393,7 @@ private void mockForPublish(String mode) { .thenReturn(true); Mockito.when(repositories.canPublishInNamespace(token.getUser(), namespace)) .thenReturn(true); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); // Mock findMemberships(user) for similarity check Mockito.when(repositories.findMemberships(token.getUser())) @@ -3401,7 +3401,7 @@ private void mockForPublish(String mode) { } else if (mode.equals("contributor") || mode.equals("sole-contributor") || mode.equals("existing")) { Mockito.when(repositories.canPublishInNamespace(token.getUser(), namespace)) .thenReturn(true); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); if (mode.equals("contributor")) { var otherUser = new UserData(); @@ -3412,7 +3412,7 @@ private void mockForPublish(String mode) { ownerMem.setRole(NamespaceMembership.ROLE_OWNER); Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.of(ownerMem)); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); // Mock findMemberships(user) for similarity check - user is a contributor var contributorMem = new NamespaceMembership(); @@ -3424,7 +3424,7 @@ private void mockForPublish(String mode) { } else { Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.empty()); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(false); // Mock findMemberships(user) for similarity check - user might be sole contributor var contributorMem = new NamespaceMembership(); @@ -3447,6 +3447,10 @@ private void mockForPublish(String mode) { .thenReturn(true); if (mode.equals("privileged")) { token.getUser().setRole(UserData.Role.PRIVILEGED); + // A privileged user bypasses per-namespace verification (RepositoryService.isVerifiedPublisher), + // regardless of namespace membership. + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) + .thenReturn(true); // Mock findMemberships(user) for similarity check - privileged user might have memberships Mockito.when(repositories.findMemberships(token.getUser())) .thenReturn(Streamable.empty()); diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 61d5b8615..c552e0f69 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -358,7 +358,7 @@ void testOwnExtensionsVerifiedAndNamespaceOwnershipConflict() throws Exception { membership.setUser(userData); membership.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); Mockito.when(repositories.findMemberships(userData)).thenReturn(Streamable.of(membership)); - Mockito.when(repositories.isVerified(namespace, userData)).thenReturn(false); + Mockito.when(repositories.isVerifiedPublisher(latest)).thenReturn(false); var scan = new ExtensionScan(); scan.setStatus(ScanStatus.QUARANTINED); diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 00fbfc78d..71873825c 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -198,6 +198,8 @@ void testExecuteQueries() { () -> repositories.countExtensions(), () -> repositories.hasMemberships(namespace, "role"), () -> repositories.isVerified(namespace, userData), + () -> repositories.isVerifiedPublisher(namespace, userData), + () -> repositories.isVerifiedPublisher(extVersion), () -> repositories.countNamespaces(), () -> repositories.countPublishersThatClaimedNamespaceOwnership(), () -> repositories.countUsers(), diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java index 4613a2e2d..7da80e2f2 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java @@ -163,7 +163,7 @@ void startScan_isClean_whenNamespaceDoesNotExistUpstream() throws Exception { .startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); - verify(repositories, never()).isVerified(any(), any()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); } @Test @@ -178,7 +178,7 @@ void startScan_isClean_whenSearchHitsAreFromUnrelatedPublishers() throws Excepti .startScan(new Scanner.Command(1L, "scan-1")); assertTrue(invocation.result().isClean()); - verify(repositories, never()).isVerified(any(), any()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); } @Test @@ -187,7 +187,7 @@ void startScan_raisesThreat_whenNamespaceExistsUpstreamAndIsNotVerified() throws var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); - when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) .startScan(new Scanner.Command(1L, "scan-1")); @@ -202,7 +202,7 @@ void startScan_isClean_whenNamespaceExistsUpstreamAndIsVerified() throws Excepti var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); - when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(true); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) .startScan(new Scanner.Command(1L, "scan-1")); @@ -219,7 +219,7 @@ void startScan_raisesThreat_whenNamespaceExistsUpstreamWithDifferentCasing() thr var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH_DIFFERENT_CASE); - when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(false); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) .startScan(new Scanner.Command(1L, "scan-1")); @@ -238,7 +238,7 @@ void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() .startScan(new Scanner.Command(1L, "scan-1")); assertFalse(invocation.result().isClean()); - verify(repositories, never()).isVerified(any(), any()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); } @Test @@ -247,7 +247,7 @@ void startScan_isClean_isActiveAndCheckActiveExtensions() throws Exception { var extVersion = extensionVersion(user); when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); - when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(true); when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); var invocation = (Scanner.Invocation.Completed) newScanner(true, true) From b2cd625c2c3fed89b5285f6a8f06d25cd036a7d9 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 24 Aug 2026 20:57:54 +0200 Subject: [PATCH 15/17] fix: correct changelog wording for the ownership-conflict UI after rebase The user settings overhaul (#2091) removed ExtensionStatusChips and moved "View in Marketplace" into ExtensionHeader, so the namespace-ownership-conflict warning and Claim Namespace action no longer sit "next to View in Marketplace" or render as a status chip -- they're now a single explanatory block rendered right below ExtensionHeader in ExtensionDetailView. Update the changelog entry to match, and apply the prettier fix `yarn lint --fix` made to the wrapped warning text. --- webui/CHANGELOG.md | 3 +-- webui/src/components/extension/extension-detail-view.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index c1beddfa9..a7a5199a3 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -8,8 +8,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Add a "Data Consistency" page to the admin dashboard (#1622): a live overview of every registered consistency check's finding count, with actions to refresh it and to fix findings one at a time or all at once - Show a "Namespace needs verification" hint on an extension card when it can't be activated because its namespace already exists in a referenced external gallery and hasn't been verified, in both the "My Extensions" and namespace member extension lists -- Add a "Claim Namespace" action to the extension settings page, next to "View in Marketplace", when the extension has a namespace ownership conflict: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured -- Show a "Namespace Not Verified" status chip and an explanatory notice on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed before it can be activated +- Show an explanatory warning notice and a "Claim Namespace" action on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed (verified) before the extension can be activated: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured ### Changed diff --git a/webui/src/components/extension/extension-detail-view.tsx b/webui/src/components/extension/extension-detail-view.tsx index 9718ee2ff..47e6d303d 100644 --- a/webui/src/components/extension/extension-detail-view.tsx +++ b/webui/src/components/extension/extension-detail-view.tsx @@ -113,8 +113,8 @@ export const ExtensionDetailView: FunctionComponent = variant='body2' sx={{ display: 'flex', alignItems: 'center', color: 'warning.main', mb: 1 }}> - This namespace already exists in a referenced gallery and needs to be claimed (verified) - before this extension can be activated. + This namespace already exists in a referenced gallery and needs to be claimed (verified) before + this extension can be activated. {ClaimNamespace ? ( From b79f2fa750b01d4e959b3ba445e8829efe34f4fb Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 24 Aug 2026 21:22:06 +0200 Subject: [PATCH 16/17] fix: surface the namespace ownership conflict on My Extensions / member lists The overhaul (#2091) moved the "My Extensions" and namespace-member extension lists from ExtensionCardListItem (which already had its own namespaceOwnershipConflict check) to the new shared ManageExtensionCard, which instead derives its status label from getExtensionStatus() -- a module that didn't know about this flag at all. The card silently fell through to "Under review" or "Deactivated" (or nothing), losing the actionable "the namespace needs to be verified" information entirely on both lists. Add a namespaceOwnershipConflict case to getExtensionStatus(), ranked right after "removed" and above every review-related state: it's usually the actual cause of the deactivation, so it's the more actionable thing to show. Add priority tests mirroring the existing ones for this function. Also fixes a real crash surfaced by the full test suite: extension-settings.spec.tsx renders ExtensionDetailView (which now reads pageSettings.elements.claimNamespace) with a pageSettings mock missing the required `elements` field, same as the one already fixed in extension-detail-view.spec.tsx. Hardens the shared test-providers.tsx default the same way so this class of bug can't recur silently for a future feature that reads pageSettings.elements. --- .../components/extension/extension-status.ts | 5 ++++ .../extension/extension-status.spec.ts | 23 ++++++++++++++++++- .../extensions/extension-settings.spec.tsx | 2 +- webui/test/unit/support/test-providers.tsx | 4 +++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/webui/src/components/extension/extension-status.ts b/webui/src/components/extension/extension-status.ts index 69ec4bcc1..d599c8f02 100644 --- a/webui/src/components/extension/extension-status.ts +++ b/webui/src/components/extension/extension-status.ts @@ -25,6 +25,11 @@ export const getExtensionStatus = (extension: Extension): ExtensionStatus | unde if (extension.removed) { return { label: 'Deleted', color: 'error.main' }; } + // Usually the reason the extension is deactivated in the first place, so it outranks that and + // every review-related state below: it names the actual, actionable cause. + if (extension.namespaceOwnershipConflict) { + return { label: 'Namespace not verified', color: 'warningAccent' }; + } switch (extension.reviewStatus) { case 'rejected': return { label: 'Rejected', color: 'error.main' }; diff --git a/webui/test/unit/components/extension/extension-status.spec.ts b/webui/test/unit/components/extension/extension-status.spec.ts index 70c58dfa5..22d5998ef 100644 --- a/webui/test/unit/components/extension/extension-status.spec.ts +++ b/webui/test/unit/components/extension/extension-status.spec.ts @@ -26,6 +26,9 @@ describe('getExtensionStatus', () => { it('reports each publishing state on its own', () => { expect(getExtensionStatus(extension({ removed: true }))?.label).toBe('Deleted'); + expect(getExtensionStatus(extension({ namespaceOwnershipConflict: true }))?.label).toBe( + 'Namespace not verified' + ); expect(getExtensionStatus(extension({ reviewStatus: 'rejected' }))?.label).toBe('Rejected'); expect(getExtensionStatus(extension({ reviewStatus: 'under_review' }))?.label).toBe('Under review'); expect(getExtensionStatus(extension({ active: false }))?.label).toBe('Deactivated'); @@ -34,11 +37,29 @@ describe('getExtensionStatus', () => { it('ranks a removed extension above every other state', () => { const status = getExtensionStatus( - extension({ removed: true, reviewStatus: 'under_review', active: false, deprecated: true }) + extension({ + removed: true, + namespaceOwnershipConflict: true, + reviewStatus: 'under_review', + active: false, + deprecated: true + }) ); expect(status?.label).toBe('Deleted'); }); + it('ranks a namespace ownership conflict above the review verdict, deactivation and deprecation', () => { + const status = getExtensionStatus( + extension({ + namespaceOwnershipConflict: true, + reviewStatus: 'under_review', + active: false, + deprecated: true + }) + ); + expect(status?.label).toBe('Namespace not verified'); + }); + it('ranks a review verdict above deactivation and deprecation', () => { const status = getExtensionStatus(extension({ reviewStatus: 'rejected', active: false, deprecated: true })); expect(status?.label).toBe('Rejected'); diff --git a/webui/test/unit/pages/user/extensions/extension-settings.spec.tsx b/webui/test/unit/pages/user/extensions/extension-settings.spec.tsx index 32555ad48..ef0b82901 100644 --- a/webui/test/unit/pages/user/extensions/extension-settings.spec.tsx +++ b/webui/test/unit/pages/user/extensions/extension-settings.spec.tsx @@ -62,7 +62,7 @@ function renderSettings(options: { backState?: ExtensionSettingsBackState; error service, user: testUser, handleError, - pageSettings: { urls: { extensionDefaultIcon: '/icon.png' } } as PageSettings + pageSettings: { urls: { extensionDefaultIcon: '/icon.png' }, elements: {} } as PageSettings }, route: { pathname: '/user-settings/extensions/foo/bar', state: options.backState } } diff --git a/webui/test/unit/support/test-providers.tsx b/webui/test/unit/support/test-providers.tsx index 4f34e3bc7..97c70ebbc 100644 --- a/webui/test/unit/support/test-providers.tsx +++ b/webui/test/unit/support/test-providers.tsx @@ -62,7 +62,9 @@ export interface ProviderOptions { function mainContextValue(overrides?: Partial): MainContext { return { service: {} as ExtensionRegistryService, - pageSettings: {} as PageSettings, + // `elements` is a required PageSettings field; the empty object keeps this default genuinely + // well-typed rather than relying on `as PageSettings` to paper over a missing required field. + pageSettings: { elements: {} } as PageSettings, handleError: () => {}, updateUser: () => {}, ...overrides From 61e6a07980f1eba46c8be852aa7c060e576d2852 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 24 Aug 2026 22:03:53 +0200 Subject: [PATCH 17/17] fix: surface the namespace ownership conflict in the admin dashboard AdminAPI.getExtension() never set namespaceOwnershipConflict on the JSON, so the admin extension page's ExtensionDetailView banner, and the admin namespace page's extension list (NamespaceExtensionList fetches through this same endpoint), silently never showed it -- only UserAPI's endpoints did. Extract the flag's computation into RepositoryService.hasThreatOfType(version, type), deduplicating what was inline in UserAPI (which also let us drop a redundant ExtensionScanRepository dependency UserAPI had bypassed the repositories facade for -- RepositoryService already had an equivalent findLatestExtensionScan). Wire it into AdminAPI.getExtension(). Fixes two UserAPITest mocks that stubbed the now-removed direct-repository call path, and adds the missing entry to RepositoryServiceSmokeTest's reflective method coverage list. Also: the extension settings page's "Claim Namespace" action doesn't make sense in the admin view -- claiming is the publisher's action, not an admin's on someone else's behalf. Reuse the existing canPurge (admin-only, per ExtensionDetailViewProps' own doc comment) to hide just the action while keeping the explanatory text, so admins still see why the extension is blocked. Full suites verified: server 938/938, webui 36 files / 156 tests. --- .../java/org/eclipse/openvsx/UserAPI.java | 14 ++------- .../org/eclipse/openvsx/admin/AdminAPI.java | 3 ++ .../repositories/RepositoryService.java | 11 +++++++ .../java/org/eclipse/openvsx/UserAPITest.java | 21 ++----------- .../eclipse/openvsx/admin/AdminAPITest.java | 16 ++++++++++ .../RepositoryServiceSmokeTest.java | 2 ++ webui/CHANGELOG.md | 2 +- .../extension/extension-detail-view.tsx | 30 ++++++++++--------- .../extension/extension-detail-view.spec.tsx | 14 +++++++++ 9 files changed, 67 insertions(+), 46 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index f73423122..c886f5cbf 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -56,7 +56,6 @@ import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.json.UsageStatsListJson; import org.eclipse.openvsx.json.UserJson; -import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.security.CodedAuthException; @@ -91,7 +90,6 @@ public class UserAPI { private final StorageUtilService storageUtil; private final LocalRegistryService local; private final ExtensionService extensions; - private final ExtensionScanRepository scanRepository; public UserAPI( RepositoryService repositories, @@ -100,8 +98,7 @@ public UserAPI( EclipseService eclipse, StorageUtilService storageUtil, LocalRegistryService local, - ExtensionService extensions, - ExtensionScanRepository scanRepository + ExtensionService extensions ) { this.repositories = repositories; this.users = users; @@ -110,7 +107,6 @@ public UserAPI( this.storageUtil = storageUtil; this.local = local; this.extensions = extensions; - this.scanRepository = scanRepository; } @GetMapping( @@ -292,13 +288,7 @@ public List getOwnExtensions() { * (scanning disabled, or the version predates the scanning feature). */ private ExtensionScan findLatestScan(ExtensionVersion extVersion) { - var ext = extVersion.getExtension(); - return scanRepository - .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( - ext.getNamespace().getName(), - ext.getName(), - extVersion.getVersion(), - extVersion.getTargetPlatform()); + return repositories.findLatestExtensionScan(extVersion); } /** diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 825d99bc0..2e89cdcba 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -61,6 +61,7 @@ import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.json.UserRelationshipsJson; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.settings.SettingsService; @@ -394,6 +395,8 @@ public ResponseEntity getExtension( repositories.findTargetPlatformsGroupedByVersion(latest.getExtension())); json.setActive(latest.getExtension().isActive()); json.setRemoved(latest.isExtensionRemoved()); + json.setNamespaceOwnershipConflict( + repositories.hasThreatOfType(latest, NamespaceOwnershipCheckScanner.TYPE)); } else { var extension = repositories.findExtension(extensionName, namespaceName); if (extension == null) { diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index fa35939e1..3805032e5 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -1143,6 +1143,17 @@ public ExtensionScan findLatestExtensionScan(ExtensionVersion version) { version.getTargetPlatform()); } + /** + * Whether the version's latest scan (if any) recorded a threat of the given type, e.g. an + * unresolved {@code NamespaceOwnershipCheckScanner.TYPE} conflict. Takes the type as a plain + * string rather than the scanner class itself, so this repository layer doesn't have to depend + * on the scanning package. + */ + public boolean hasThreatOfType(ExtensionVersion version, String type) { + var scan = findLatestExtensionScan(version); + return scan != null && findExtensionThreats(scan, type).stream().findAny().isPresent(); + } + public Streamable findExtensionScans(Extension extension) { var namespace = extension.getNamespace(); return extensionScanRepo.findByNamespaceNameAndExtensionName(namespace.getName(), extension.getName()); diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index c552e0f69..3047ba159 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -120,9 +120,6 @@ class UserAPITest { @Autowired StorageUtilService storageUtil; - @MockitoBean - org.eclipse.openvsx.repositories.ExtensionScanRepository scanRepository; - @Autowired MockMvc mockMvc; @Autowired @@ -362,14 +359,7 @@ void testOwnExtensionsVerifiedAndNamespaceOwnershipConflict() throws Exception { var scan = new ExtensionScan(); scan.setStatus(ScanStatus.QUARANTINED); - Mockito.when( - scanRepository - .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( - namespace.getName(), - extension.getName(), - latest.getVersion(), - TargetPlatform.NAME_UNIVERSAL)) - .thenReturn(scan); + Mockito.when(repositories.findLatestExtensionScan(latest)).thenReturn(scan); Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) .thenReturn( Streamable.of( @@ -511,14 +501,7 @@ void testGetOwnExtensionNamespaceOwnershipConflict() throws Exception { var scan = new ExtensionScan(); scan.setStatus(ScanStatus.QUARANTINED); - Mockito.when( - scanRepository - .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( - "foobar", - "baz", - latest.getVersion(), - TargetPlatform.NAME_UNIVERSAL)) - .thenReturn(scan); + Mockito.when(repositories.findLatestExtensionScan(latest)).thenReturn(scan); Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) .thenReturn( Streamable.of( diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 419bcdc1e..4cfe4bc37 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -84,6 +84,7 @@ import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -119,6 +120,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -225,6 +227,20 @@ void testGetInactiveExtension() throws Exception { }))); } + @Test + void testGetExtensionNamespaceOwnershipConflict() throws Exception { + mockAdminUser(); + var latest = mockExtension(2, 0, 0).getLast(); + when(repositories.hasThreatOfType(latest, NamespaceOwnershipCheckScanner.TYPE)).thenReturn(true); + + mockMvc.perform( + get("/admin/extension/{namespace}/{extension}", "foobar", "baz") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.namespaceOwnershipConflict").value(true)); + } + @Test void testAddNamespaceMemberNotLoggedIn() throws Exception { mockMvc.perform( diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 71873825c..08189ccf2 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -49,6 +49,7 @@ import org.eclipse.openvsx.entities.UsageStats; import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.QueryRequest; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.util.ChangesCursor; import org.eclipse.openvsx.util.ExtensionId; @@ -389,6 +390,7 @@ void testExecuteQueries() { () -> repositories.findSimilarNamespacesByLevenshtein("namespaceName", List.of(), 0.5, false, 10), () -> repositories.findExtensionScans(extVersion), () -> repositories.findLatestExtensionScan(extVersion), + () -> repositories.hasThreatOfType(extVersion, NamespaceOwnershipCheckScanner.TYPE), () -> repositories.findExtensionScans(extension), () -> repositories.findExtensionScansByNamespace(namespace.getName()), () -> repositories.findExtensionScansByStatus(ScanStatus.STARTED), diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index a7a5199a3..1f3cc6f05 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -8,7 +8,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Add a "Data Consistency" page to the admin dashboard (#1622): a live overview of every registered consistency check's finding count, with actions to refresh it and to fix findings one at a time or all at once - Show a "Namespace needs verification" hint on an extension card when it can't be activated because its namespace already exists in a referenced external gallery and hasn't been verified, in both the "My Extensions" and namespace member extension lists -- Show an explanatory warning notice and a "Claim Namespace" action on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed (verified) before the extension can be activated: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured +- Show an explanatory warning notice and a "Claim Namespace" action on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed (verified) before the extension can be activated: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured. The admin dashboard's extension and namespace views show the same explanation, but not the claim action, since claiming is the publisher's action to take, not an admin's on someone else's behalf ### Changed diff --git a/webui/src/components/extension/extension-detail-view.tsx b/webui/src/components/extension/extension-detail-view.tsx index 47e6d303d..2d5b4b767 100644 --- a/webui/src/components/extension/extension-detail-view.tsx +++ b/webui/src/components/extension/extension-detail-view.tsx @@ -116,20 +116,22 @@ export const ExtensionDetailView: FunctionComponent = This namespace already exists in a referenced gallery and needs to be claimed (verified) before this extension can be activated. - {ClaimNamespace ? ( - - ) : ( - // Fallback for a deployment that hasn't configured `elements.claimNamespace`: - // point at the generic namespace-access docs instead of showing nothing. - - )} + {/* Claiming is the publisher's action to take, not an admin's on someone else's behalf. */} + {!canPurge && + (ClaimNamespace ? ( + + ) : ( + // Fallback for a deployment that hasn't configured `elements.claimNamespace`: + // point at the generic namespace-access docs instead of showing nothing. + + ))} )} General diff --git a/webui/test/unit/components/extension/extension-detail-view.spec.tsx b/webui/test/unit/components/extension/extension-detail-view.spec.tsx index fca39e7f3..27100f268 100644 --- a/webui/test/unit/components/extension/extension-detail-view.spec.tsx +++ b/webui/test/unit/components/extension/extension-detail-view.spec.tsx @@ -157,6 +157,20 @@ describe('ExtensionDetailView', () => { expect(link).toHaveAttribute('href', 'https://example.test/namespace-access'); }); + it('hides the claim action in the admin view, keeping the explanation', () => { + // A purge handler is only ever supplied by the admin dashboard (see ExtensionDetailViewProps). + renderDetail([], vi.fn().mockResolvedValue(undefined), { + extension: { active: false, namespaceOwnershipConflict: true }, + pageSettings: { elements: { claimNamespace: ClaimNamespaceStub } } + }); + + expect(screen.queryByText('Claim foo')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Claim Namespace' })).not.toBeInTheDocument(); + expect( + screen.getByText(/needs to be claimed \(verified\) before this extension can be activated/) + ).toBeInTheDocument(); + }); + it('explains that the namespace needs to be claimed before the extension can be activated', () => { renderDetail([], undefined, { extension: { active: false, namespaceOwnershipConflict: true },