diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java index 35bfb52c8..2f74efdd0 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.MissingNode; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import jakarta.annotation.Nullable; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.adapter.ExtensionQueryResult; @@ -472,7 +473,7 @@ private String tryGetAssetPath(String type) { return null; } - protected TempFile getIcon(ExtensionVersion extVersion) throws IOException { + public @Nullable TempFile getIcon(ExtensionVersion extVersion) throws IOException { var iconPath = tryGetAssetPath(ExtensionQueryResult.ExtensionFile.FILE_ICON); if (StringUtils.isEmpty(iconPath)) { loadPackageJson(); diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java index 641ef0b43..a39ba2596 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java @@ -21,15 +21,15 @@ import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.util.*; import org.jobrunr.scheduling.JobRequestScheduler; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import java.io.BufferedInputStream; import java.io.IOException; @@ -42,10 +42,9 @@ import java.util.Objects; import java.util.stream.Collectors; -@Component +@Service public class ExtensionService { - private static final int MAX_CONTENT_SIZE = 512 * 1024 * 1024; - + private final PublishingConfig publishingConfig; private final EntityManager entityManager; private final RepositoryService repositories; private final SearchUtilService search; @@ -56,10 +55,8 @@ public class ExtensionService { private final ExtensionScanService scanService; private final ExtensionScanPersistenceService scanPersistenceService; - @Value("${ovsx.publishing.max-content-size:" + MAX_CONTENT_SIZE + "}") - int maxContentSize; - public ExtensionService( + PublishingConfig publishingConfig, EntityManager entityManager, RepositoryService repositories, SearchUtilService search, @@ -70,6 +67,7 @@ public ExtensionService( ExtensionScanService scanService, ExtensionScanPersistenceService scanPersistenceService ) { + this.publishingConfig = publishingConfig; this.entityManager = entityManager; this.repositories = repositories; this.search = search; @@ -81,14 +79,8 @@ public ExtensionService( this.scanPersistenceService = scanPersistenceService; } - // For testing only - boolean isLicenseRequired() { - return publishHandler.isLicenseRequired(); - } - - // For testing only - void setLicenseRequired(boolean requireLicense) { - publishHandler.setLicenseRequired(requireLicense); + private long getMaxContentSize() { + return publishingConfig.getMaxContentSize(); } @Transactional @@ -164,6 +156,7 @@ private void doPublish(TempFile extensionFile, String binaryName, PersonalAccess } private TempFile createExtensionFile(InputStream content) { + long maxContentSize = getMaxContentSize(); try (var input = ByteStreams.limit(new BufferedInputStream(content), maxContentSize + 1)) { long size; var extensionFile = new TempFile("extension_", ".vsix"); diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index 484cd37fd..d40ad7f18 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -9,9 +9,14 @@ * ****************************************************************************** */ package org.eclipse.openvsx.publish; -import com.google.common.base.Joiner; -import jakarta.persistence.EntityManager; -import jakarta.transaction.Transactional; +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.ExtensionProcessor; @@ -19,33 +24,37 @@ import org.eclipse.openvsx.ExtensionValidator; import org.eclipse.openvsx.UserService; import org.eclipse.openvsx.adapter.VSCodeIdNewExtensionJobRequest; -import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanService; -import org.eclipse.openvsx.util.*; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.ExtensionId; +import org.eclipse.openvsx.util.NamingUtil; +import org.eclipse.openvsx.util.TempFile; import org.jobrunr.scheduling.JobRequestScheduler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerErrorException; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.List; -import java.util.function.Consumer; +import com.google.common.base.Joiner; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; @Component public class PublishExtensionVersionHandler { - protected final Logger logger = LoggerFactory.getLogger(PublishExtensionVersionHandler.class); - @Value("${ovsx.publishing.require-license:false}") - boolean requireLicense; - + private final PublishingConfig config; private final PublishExtensionVersionService service; private final ExtensionVersionIntegrityService integrityService; private final EntityManager entityManager; @@ -56,7 +65,10 @@ public class PublishExtensionVersionHandler { private final ExtensionControlService extensionControl; private final ExtensionScanService scanService; + private final Predicate unsupportedIconExtensions; + public PublishExtensionVersionHandler( + PublishingConfig config, PublishExtensionVersionService service, ExtensionVersionIntegrityService integrityService, EntityManager entityManager, @@ -67,6 +79,7 @@ public PublishExtensionVersionHandler( ExtensionControlService extensionControl, ExtensionScanService scanService ) { + this.config = config; this.service = service; this.integrityService = integrityService; this.entityManager = entityManager; @@ -76,14 +89,19 @@ public PublishExtensionVersionHandler( this.validator = validator; this.extensionControl = extensionControl; this.scanService = scanService; - } - public boolean isLicenseRequired() { - return requireLicense; + this.unsupportedIconExtensions = path -> { + if (path == null) { + return false; + } + + var fileExtension = FilenameUtils.getExtension(path.toString()); + return config.getUnsupportedIconFormats().stream().anyMatch(ext -> ext.equalsIgnoreCase(fileExtension)); + }; } - public void setLicenseRequired(boolean requireLicense) { - this.requireLicense = requireLicense; + public boolean isLicenseRequired() { + return config.isRequireLicense(); } @Transactional(rollbackOn = ErrorResultException.class) @@ -160,6 +178,7 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us extVersion.setExtension(extension); validateLicense(processor, extVersion); + validateIcon(processor, extVersion); validateMetadata(extVersion); entityManager.persist(extVersion); return extVersion; @@ -185,7 +204,7 @@ private void validateExtensionName(String namespaceName, String extensionName, S } private void validateLicense(ExtensionProcessor processor, ExtensionVersion extVersion) { - if (requireLicense) { + if (isLicenseRequired()) { // Check the extension's license try (var licenseFile = processor.getLicense(extVersion)) { checkLicense(extVersion, licenseFile); @@ -202,6 +221,16 @@ private void checkLicense(ExtensionVersion extVersion, TempFile licenseFile) { } } + private void validateIcon(ExtensionProcessor processor, ExtensionVersion extVersion) { + try (var iconFile = processor.getIcon(extVersion)) { + if (iconFile != null && unsupportedIconExtensions.test(iconFile.getPath())) { + throw new ErrorResultException("This extension cannot be accepted as it uses an unsupported icon format."); + } + } catch (IOException e) { + throw new ServerErrorException("Failed to read icon file", e); + } + } + private void validateMetadata(ExtensionVersion extVersion) { var metadataIssues = validator.validateMetadata(extVersion); if (!metadataIssues.isEmpty()) { diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishingConfig.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishingConfig.java new file mode 100644 index 000000000..6e08c6c73 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishingConfig.java @@ -0,0 +1,60 @@ +/****************************************************************************** + * 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.publish; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@ConfigurationProperties("ovsx.publishing") +public class PublishingConfig { + private static final int MAX_CONTENT_SIZE = 512 * 1024 * 1024; + + private long maxContentSize = MAX_CONTENT_SIZE; + + private boolean requireLicense; + + /** + * Allows to specify a list of unsupported icon formats identified by their extension. + *

+ * See: Block SVG Images + */ + private List unsupportedIconFormats = List.of("svg"); + + public long getMaxContentSize() { + return maxContentSize; + } + + public void setMaxContentSize(int maxContentSize) { + this.maxContentSize = maxContentSize; + } + + public boolean isRequireLicense() { + return requireLicense; + } + + public void setRequireLicense(boolean requireLicense) { + this.requireLicense = requireLicense; + } + + public List getUnsupportedIconFormats() { + return unsupportedIconFormats; + } + + public void setUnsupportedIconFormats(List unsupportedIconFormats) { + this.unsupportedIconFormats = unsupportedIconFormats; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/util/TempFile.java b/server/src/main/java/org/eclipse/openvsx/util/TempFile.java index 62ece2b80..3a077d47f 100644 --- a/server/src/main/java/org/eclipse/openvsx/util/TempFile.java +++ b/server/src/main/java/org/eclipse/openvsx/util/TempFile.java @@ -61,6 +61,6 @@ public void setNamespace(Namespace namespace) { @Override public void close() throws IOException { - Files.delete(path); + Files.deleteIfExists(path); } } diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 61f6b5574..e8a8ac056 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -29,6 +29,7 @@ import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.publish.PublishExtensionVersionService; +import org.eclipse.openvsx.publish.PublishingConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; @@ -120,6 +121,9 @@ class RegistryAPITest { @Autowired MockMvc mockMvc; + @Autowired + PublishingConfig publishingConfig; + @Autowired ExtensionService extensionService; @@ -1525,9 +1529,9 @@ void testPublishOrphan() throws Exception { @Test void testPublishRequireLicenseNone() throws Exception { - var previousRequireLicense = extensionService.isLicenseRequired(); + var previousRequireLicense = publishingConfig.isRequireLicense(); try { - extensionService.setLicenseRequired(true); + publishingConfig.setRequireLicense(true); mockForPublish("contributor"); var bytes = createExtensionPackage("bar", "1.0.0", null); mockMvc.perform(post("/api/-/publish?token={token}", "my_token") @@ -1536,15 +1540,15 @@ void testPublishRequireLicenseNone() throws Exception { .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("This extension cannot be accepted because it has no license."))); } finally { - extensionService.setLicenseRequired(previousRequireLicense); + publishingConfig.setRequireLicense(previousRequireLicense); } } @Test void testPublishRequireLicenseOk() throws Exception { - var previousRequireLicense = extensionService.isLicenseRequired(); + var previousRequireLicense = publishingConfig.isRequireLicense(); try { - extensionService.setLicenseRequired(true); + publishingConfig.setRequireLicense(true); mockForPublish("contributor"); mockActiveVersion(); var bytes = createExtensionPackage("bar", "1.0.0", "MIT"); @@ -1563,7 +1567,7 @@ void testPublishRequireLicenseOk() throws Exception { e.setDownloadable(true); }))); } finally { - extensionService.setLicenseRequired(previousRequireLicense); + publishingConfig.setRequireLicense(previousRequireLicense); } } @@ -2617,8 +2621,14 @@ LocalRegistryService localRegistryService( ); } + @Bean + PublishingConfig publishingConfig() { + return new PublishingConfig(); + } + @Bean PublishExtensionVersionHandler publishExtensionVersionHandler( + PublishingConfig publishingConfig, PublishExtensionVersionService service, ExtensionVersionIntegrityService integrityService, EntityManager entityManager, @@ -2630,6 +2640,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( ExtensionScanService extensionScanService ) { return new PublishExtensionVersionHandler( + publishingConfig, service, integrityService, entityManager, @@ -2644,6 +2655,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( @Bean ExtensionService extensionService( + PublishingConfig publishingConfig, EntityManager entityManager, RepositoryService repositories, SearchUtilService search, @@ -2655,6 +2667,7 @@ ExtensionService extensionService( ExtensionScanPersistenceService scanPersistenceService ) { return new ExtensionService( + publishingConfig, entityManager, repositories, search, diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 8c21a0c1f..8934cbb4f 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -24,6 +24,7 @@ import org.eclipse.openvsx.mail.MailService; import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; @@ -895,6 +896,7 @@ ExtensionService extensionService( ExtensionScanPersistenceService scanPersistenceService ) { return new ExtensionService( + new PublishingConfig(), entityManager, repositories, search, 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 22a580713..6c64b1a2a 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -26,6 +26,7 @@ import org.eclipse.openvsx.mail.MailService; import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; @@ -1586,6 +1587,7 @@ ExtensionService extensionService( ExtensionScanPersistenceService scanPersistenceService ) { return new ExtensionService( + new PublishingConfig(), entityManager, repositories, search, diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index 6552d775e..7d2dd0d4f 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -21,6 +21,7 @@ import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; +import org.eclipse.openvsx.publish.PublishingConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; @@ -420,6 +421,7 @@ ExtensionService extensionService( ExtensionScanPersistenceService scanPersistenceService ) { return new ExtensionService( + new PublishingConfig(), entityManager, repositories, search, diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java index b1fe8341c..4eb3761ee 100644 --- a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java @@ -1,160 +1,227 @@ -/******************************************************************************** - * Copyright (c) 2025 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ -package org.eclipse.openvsx.publish; - -import jakarta.persistence.EntityManager; -import org.eclipse.openvsx.ExtensionProcessor; -import org.eclipse.openvsx.ExtensionValidator; -import org.eclipse.openvsx.UserService; -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.extension_control.ExtensionControlService; -import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.ExtensionScanService; -import org.eclipse.openvsx.util.ErrorResultException; -import org.jobrunr.scheduling.JobRequestScheduler; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PublishExtensionVersionHandlerTest { - - @Mock - PublishExtensionVersionService publishService; - - @Mock - ExtensionVersionIntegrityService integrityService; - - @Mock - EntityManager entityManager; - - @Mock - RepositoryService repositories; - - @Mock - JobRequestScheduler scheduler; - - @Mock - UserService users; - - @Mock - ExtensionValidator validator; - - @Mock - ExtensionControlService extensionControl; - - @Mock - ExtensionScanService scanService; - - private PublishExtensionVersionHandler handler; - - @BeforeEach - void setUp() throws Exception { - handler = new PublishExtensionVersionHandler( - publishService, - integrityService, - entityManager, - repositories, - scheduler, - users, - validator, - extensionControl, - scanService - ); - - // Lenient: not all tests need this mock - org.mockito.Mockito.lenient() - .when(extensionControl.getMaliciousExtensionIds()) - .thenReturn(Collections.emptyList()); - } - - @Test - void shouldCreateExtensionWhenNamespaceExists() { - // Happy path: extension version gets persisted. - var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); - when(processor.getNamespace()).thenReturn("publisher"); - when(processor.getExtensionName()).thenReturn("demo"); - when(processor.getVersion()).thenReturn("2.0.0"); - when(processor.getExtensionDependencies()).thenReturn(List.of()); - when(processor.getBundledExtensions()).thenReturn(List.of()); - - var metadata = new ExtensionVersion(); - metadata.setDisplayName("Demo OK"); - metadata.setVersion("2.0.0"); - metadata.setTargetPlatform("any"); - when(processor.getMetadata()).thenReturn(metadata); - - var namespace = buildNamespace("publisher"); - var user = new org.eclipse.openvsx.entities.UserData(); - var token = new PersonalAccessToken(); - token.setUser(user); - - when(repositories.findNamespace("publisher")).thenReturn(namespace); - when(users.hasPublishPermission(user, namespace)).thenReturn(true); - when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); - when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); - when(validator.validateMetadata(metadata)).thenReturn(List.of()); - when(repositories.findExtension("demo", namespace)).thenReturn(null); - - var capturedExtension = ArgumentCaptor.forClass(Extension.class); - - var result = handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); - - verify(entityManager).persist(capturedExtension.capture()); - verify(entityManager).persist(metadata); - assertThat(result).isSameAs(metadata); - assertThat(result.getPublishedWith()).isEqualTo(token); - assertThat(result.getExtension()).isSameAs(capturedExtension.getValue()); - assertThat(result.getExtension().getNamespace()).isSameAs(namespace); - } - - @Test - void shouldFailWhenNamespaceDoesNotExist() { - // When namespace doesn't exist, handler should throw an error. - var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); - when(processor.getNamespace()).thenReturn("unknown"); - - var user = new org.eclipse.openvsx.entities.UserData(); - var token = new PersonalAccessToken(); - token.setUser(user); - - when(repositories.findNamespace("unknown")).thenReturn(null); - - assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) - .isInstanceOf(ErrorResultException.class) - .hasMessageContaining("Unknown publisher"); - } - - private Namespace buildNamespace(String name) { - var namespace = new Namespace(); - namespace.setName(name); - return namespace; - } -} - +/******************************************************************************** + * Copyright (c) 2025 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 v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.publish; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.eclipse.openvsx.ExtensionProcessor; +import org.eclipse.openvsx.ExtensionValidator; +import org.eclipse.openvsx.UserService; +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.extension_control.ExtensionControlService; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TempFile; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; + +@ExtendWith(MockitoExtension.class) +class PublishExtensionVersionHandlerTest { + + @Mock + PublishExtensionVersionService publishService; + + @Mock + ExtensionVersionIntegrityService integrityService; + + @Mock + EntityManager entityManager; + + @Mock + RepositoryService repositories; + + @Mock + JobRequestScheduler scheduler; + + @Mock + UserService users; + + @Mock + ExtensionValidator validator; + + @Mock + ExtensionControlService extensionControl; + + @Mock + ExtensionScanService scanService; + + private PublishingConfig config; + + private PublishExtensionVersionHandler handler; + + @BeforeEach + void setUp() throws Exception { + config = new PublishingConfig(); + + handler = new PublishExtensionVersionHandler( + config, + publishService, + integrityService, + entityManager, + repositories, + scheduler, + users, + validator, + extensionControl, + scanService + ); + + // Lenient: not all tests need this mock + org.mockito.Mockito.lenient() + .when(extensionControl.getMaliciousExtensionIds()) + .thenReturn(Collections.emptyList()); + } + + @Test + void shouldCreateExtensionWhenNamespaceExists() throws IOException { + // Happy path: extension version gets persisted. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + var metadata = mockExtensionVersion("publisher", "demo", "2.0.0", null, processor); + + when(processor.getExtensionDependencies()).thenReturn(List.of()); + when(processor.getBundledExtensions()).thenReturn(List.of()); + + var namespace = buildNamespace("publisher"); + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(validator.validateMetadata(metadata)).thenReturn(List.of()); + when(repositories.findExtension("demo", namespace)).thenReturn(null); + + var capturedExtension = ArgumentCaptor.forClass(Extension.class); + + var result = handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(entityManager).persist(capturedExtension.capture()); + verify(entityManager).persist(metadata); + assertThat(result).isSameAs(metadata); + assertThat(result.getPublishedWith()).isEqualTo(token); + assertThat(result.getExtension()).isSameAs(capturedExtension.getValue()); + assertThat(result.getExtension().getNamespace()).isSameAs(namespace); + } + } + + @Test + void shouldFailWhenNamespaceDoesNotExist() { + // When namespace doesn't exist, handler should throw an error. + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + when(processor.getNamespace()).thenReturn("unknown"); + + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("unknown")).thenReturn(null); + + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Unknown publisher"); + } + } + + @Test + void shouldFailWhenImageFormatIsDisallowed() throws IOException { + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + mockExtensionVersion("publisher", "demo", "2.0.0", "test.svg", processor); + + var namespace = buildNamespace("publisher"); + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("uses an unsupported icon format"); + } + } + + @Test + void shouldSucceedWhenImageFormatIsAllowed() throws IOException { + var previousUnsupportedIconFormats = config.getUnsupportedIconFormats(); + try (var processor = org.mockito.Mockito.mock(ExtensionProcessor.class)) { + config.setUnsupportedIconFormats(List.of()); + + var metadata = mockExtensionVersion("publisher", "demo", "2.0.0", "test.svg", processor); + + var namespace = buildNamespace("publisher"); + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion(metadata.getVersion())).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + + var ev = handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + assertThat(ev).isNotNull(); + } finally { + config.setUnsupportedIconFormats(previousUnsupportedIconFormats); + } + } + + private ExtensionVersion mockExtensionVersion(String namespace, String name, String version, String iconPath, ExtensionProcessor processor) throws IOException { + when(processor.getNamespace()).thenReturn(namespace); + when(processor.getExtensionName()).thenReturn(name); + when(processor.getVersion()).thenReturn(version); + if (iconPath != null) { + when(processor.getIcon(ArgumentMatchers.any())).thenReturn(new TempFile(Path.of(iconPath))); + } + + var ev = new ExtensionVersion(); + ev.setDisplayName("Demo OK"); + ev.setVersion("2.0.0"); + ev.setTargetPlatform("any"); + when(processor.getMetadata()).thenReturn(ev); + + return ev; + } + + private Namespace buildNamespace(String name) { + var namespace = new Namespace(); + namespace.setName(name); + return namespace; + } +}