diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index fff6c79e5..35cdc99f3 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -18,6 +18,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.SemanticVersion; import org.eclipse.openvsx.json.*; @@ -26,10 +28,8 @@ import org.eclipse.openvsx.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.CacheControl; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @@ -43,6 +43,7 @@ import static org.eclipse.openvsx.util.TargetPlatform.*; @RestController +@Validated public class RegistryAPI { private static final int REVIEW_TITLE_SIZE = 255; private static final int REVIEW_COMMENT_SIZE = 2048; @@ -191,18 +192,6 @@ private String namespaceNotFoundMessage(String namespace) { return "Namespace not found: " + namespace; } - private String negativeSizeMessage() { - return negativeParameterMessage("size"); - } - - private String negativeOffsetMessage() { - return negativeParameterMessage("offset"); - } - - private String negativeParameterMessage(String field) { - return "The parameter '" + field + "' must not be negative."; - } - @GetMapping( path = "/api/{namespace}/logo/{fileName}", produces = { MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE } @@ -436,9 +425,12 @@ public ResponseEntity getVersions( @PathVariable @Parameter(description = "Extension name", example = "vim") String extension, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not exceed 100") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { @@ -479,9 +471,12 @@ public ResponseEntity getVersions( ) String targetPlatform, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not exceed 100") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { @@ -489,14 +484,6 @@ public ResponseEntity getVersions( } private ResponseEntity handleGetVersions(String namespace, String extension, String targetPlatform, int size, int offset) { - if (size < 0) { - var json = VersionsJson.error(negativeSizeMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - var json = VersionsJson.error(negativeOffsetMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -534,9 +521,12 @@ public ResponseEntity getVersionReferences( @PathVariable @Parameter(description = "Extension name", example = "svelte-vscode") String extension, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not exceed 100") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { @@ -577,9 +567,12 @@ public ResponseEntity getVersionReferences( ) String targetPlatform, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not exceed 100") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { @@ -587,14 +580,6 @@ public ResponseEntity getVersionReferences( } private ResponseEntity handleGetVersionReferences(String namespace, String extension, String targetPlatform, int size, int offset) { - if (size < 0) { - var json = VersionReferencesJson.error(negativeSizeMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - var json = VersionReferencesJson.error(negativeOffsetMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -792,9 +777,12 @@ public ResponseEntity search( ) String targetPlatform, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 1000, message = "parameter must not exceed 1000") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "1000", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset, @RequestParam(defaultValue = "desc") @@ -807,15 +795,6 @@ public ResponseEntity search( @Parameter(description = "Whether to include information on all available versions for each returned entry") boolean includeAllVersions ) { - if (size < 0) { - var json = SearchResultJson.error(negativeSizeMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - var json = SearchResultJson.error(negativeOffsetMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - var options = new ISearchService.Options(query, category, targetPlatform, size, offset, sortOrder, sortBy, includeAllVersions, null); var resultOffset = 0; var resultSize = 0; @@ -918,21 +897,16 @@ public ResponseEntity getQueryV2( ) String targetPlatform, @RequestParam(defaultValue = "100") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "100")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 1000, message = "parameter must not exceed 1000") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "1000", defaultValue = "100")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { - if (size < 0) { - var json = QueryResultJson.error(negativeSizeMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - var json = QueryResultJson.error(negativeOffsetMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if(!List.of("true", "false", "links").contains(includeAllVersions)) { + if (!List.of("true", "false", "links").contains(includeAllVersions)) { var json = QueryResultJson.error("Invalid includeAllVersions value: " + includeAllVersions + "."); return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } @@ -1035,21 +1009,15 @@ public ResponseEntity getQuery( ) String targetPlatform, @RequestParam(defaultValue = "100") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "100")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 1000, message = "parameter must not exceed 1000") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "1000", defaultValue = "100")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip (usually a multiple of the page size)", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset ) { - if (size < 0) { - var json = QueryResultJson.error(negativeSizeMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - var json = QueryResultJson.error(negativeOffsetMessage()); - return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); - } - var request = new QueryRequest( namespaceName, extensionName, 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 1ea05f8a0..6acac5928 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -42,6 +42,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.util.Streamable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -203,6 +204,10 @@ public ResponseEntity> getLog( try { admins.checkAdminUser(); + if (pageable.getPageSize() > 1000) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Page size must not exceed 1000"); + } + Page logsPage; if (StringUtils.isEmpty(periodString)) { logsPage = repositories.findPersistedLogsPaginated(pageable); diff --git a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java index 864e5b25a..f3b5c3767 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java @@ -17,6 +17,8 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.eclipse.openvsx.entities.FileDecision; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; @@ -27,6 +29,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -38,6 +41,7 @@ * Provides endpoints for managing file-level security decisions. */ @RestController +@Validated @RequestMapping("/admin/scans") @ApiResponse( responseCode = "403", @@ -82,9 +86,12 @@ public ResponseEntity getFiles( @Parameter(description = "Filter by display name, extension name, or file name") String name, @RequestParam(defaultValue = "18") - @Parameter(description = "Maximum number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not be larger than 100") + @Parameter(description = "Maximum number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "18")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset, @RequestParam(defaultValue = "dateDecided") @@ -103,13 +110,6 @@ public ResponseEntity getFiles( try { admins.checkAdminUser(); - if (size < 0) { - throw new ErrorResultException("Parameter 'size' must be >= 0", HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - throw new ErrorResultException("Parameter 'offset' must be >= 0", HttpStatus.BAD_REQUEST); - } - var decidedFrom = parseUtcDateTime(dateDecidedFrom, "dateDecidedFrom"); var decidedTo = parseUtcDateTime(dateDecidedTo, "dateDecidedTo"); var dbSortField = toFileSortField(sortBy); diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index 0021ab641..cc3a3fffb 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -21,6 +21,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; @@ -32,6 +34,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -51,6 +54,7 @@ * Used by the admin dashboard to monitor extension validation and scanning. */ @RestController +@Validated @RequestMapping("/admin/scans") @ApiResponse( responseCode = "403", @@ -205,11 +209,11 @@ public ResponseEntity getScanCounts( /** * Enforcement filter for Scan API /scans/counts. - * + *

* - ALL: no filtering * - ENFORCED: scans that have at least one enforced validation or threat * - NOT_ENFORCED: scans that have at least one non-enforced validation or threat - * + *

* Threat scanning enforcement will be added when threats are persisted. */ private enum EnforcementFilter { @@ -273,9 +277,12 @@ public ResponseEntity getAllScans( @Parameter(description = "Filter by display name or extension name (partial matches supported)") String name, @RequestParam(defaultValue = "10") - @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "10")) + @Min(value = 0, message = "parameter must not be negative") + @Max(value = 100, message = "parameter must not be larger than 100") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", maximum = "100", defaultValue = "10")) int size, @RequestParam(defaultValue = "0") + @Min(value = 0, message = "parameter must not be negative") @Parameter(description = "Number of entries to skip", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) int offset, @RequestParam(defaultValue = "scanEndTime") @@ -324,13 +331,6 @@ public ResponseEntity getAllScans( try { admins.checkAdminUser(); - if (size < 0) { - throw new ErrorResultException("Parameter 'size' must be >= 0", HttpStatus.BAD_REQUEST); - } - if (offset < 0) { - throw new ErrorResultException("Parameter 'offset' must be >= 0", HttpStatus.BAD_REQUEST); - } - var statusFilter = parseStatusFilter(status); var normalizedPublisher = normalizeSearch(publisher); var normalizedNamespace = normalizeSearch(namespace); @@ -624,12 +624,12 @@ public ResponseEntity retryFailedScannerJobs( * Make security decisions for one or more quarantined scans. * Only valid for scans with QUARANTINED status. * Pass a single scanId for individual decisions, or multiple scanIds for bulk operations. - * + *

* When a scan is allowed: * - The extension is automatically activated * - The scan status is updated to PASSED * - File decisions are created to add enforced threat files to allow list - * + *

* When a scan is blocked: * - The extension remains inactive * - File decisions are created to add enforced threat files to block list @@ -1100,7 +1100,7 @@ private LocalDateTime parseUtcDateTime(String raw, String paramName) { /** * Parses one or multiple status filter values into a set of ScanStatus values. - * + *

* Supports: * - one value: status=PASSED * - multiple values (comma-separated with explode=false): status=PASSED,ERROR diff --git a/server/src/main/java/org/eclipse/openvsx/web/ValidationExceptionHandler.java b/server/src/main/java/org/eclipse/openvsx/web/ValidationExceptionHandler.java new file mode 100644 index 000000000..407c49e88 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/web/ValidationExceptionHandler.java @@ -0,0 +1,56 @@ +/****************************************************************************** + * 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.web; + +import com.google.common.collect.Iterables; +import jakarta.validation.ConstraintViolationException; +import org.eclipse.openvsx.json.ResultJson; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.HandlerMethod; + +import java.lang.reflect.Method; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class ValidationExceptionHandler { + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex, HandlerMethod handlerMethod) { + try { + ResolvableType resolvableType = ResolvableType.forMethodReturnType(handlerMethod.getMethod()); + + // Get the first type argument (e.g. Foo from ResponseEntity) + Class typeArg = resolvableType.getGeneric(0).resolve(); + if (typeArg != null && ResultJson.class.isAssignableFrom(typeArg)) { + var errors = ex.getConstraintViolations().stream() + .map(cv -> Iterables.getLast(cv.getPropertyPath()) + ": " + cv.getMessage()) + .collect(Collectors.joining(", ")); + + Method staticMethod = typeArg.getMethod("error", String.class); + var json = (ResultJson) staticMethod.invoke(null, errors); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); + } + } catch (Exception _) {} + + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setTitle("Validation failed"); + problem.setProperty("errors", ex.getConstraintViolations().stream() + .map(cv -> Iterables.getLast(cv.getPropertyPath()) + ": " + cv.getMessage()) + .toList()); + return new ResponseEntity<>(problem, HttpStatus.BAD_REQUEST); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 0f8915e2d..c386b370b 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -579,6 +579,21 @@ void testReviews() throws Exception { }))); } + @Test + void testInvalidSearch() throws Exception { + mockMvc.perform(get("/api/-/search?query={query}&size={size}&offset={offset}", "foo", "-1", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/api/-/search?query={query}&size={size}&offset={offset}", "foo", "1001", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not exceed 1000")); + + mockMvc.perform(get("/api/-/search?query={query}&size={size}&offset={offset}", "foo", "1", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + @Test void testSearch() throws Exception { var extVersions = mockSearch(); @@ -720,6 +735,21 @@ void testSearchInactive() throws Exception { .andExpect(content().string("{\"offset\":0,\"totalSize\":1,\"extensions\":[]}")); } + @Test + void testInvalidQuery() throws Exception { + mockMvc.perform(get("/api/-/query?size={size}&offset={offset}", "-1", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/api/-/query?size={size}&offset={offset}", "1001", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not exceed 1000")); + + mockMvc.perform(get("/api/-/query?size={size}&offset={offset}", "100", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + @Test void testGetQueryExtensionName() throws Exception { mockExtensionVersion(); @@ -878,6 +908,21 @@ void testGetQueryMultipleTargets() throws Exception { }))); } + @Test + void testInvalidQueryV2() throws Exception { + mockMvc.perform(get("/api/v2/-/query?size={size}&offset={offset}", "-1", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/api/v2/-/query?size={size}&offset={offset}", "1001", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not exceed 1000")); + + mockMvc.perform(get("/api/v2/-/query?size={size}&offset={offset}", "100", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + @Test void testGetQueryV2ExtensionName() throws Exception { mockExtensionVersion(); @@ -1903,6 +1948,35 @@ void testDeleteNonExistingReview() throws Exception { .andExpect(content().json(errorJson("You have not submitted any review yet."))); } + @Test + void testInvalidGetVersions() throws Exception { + mockMvc.perform(get("/api/{namespace}/{extension}/versions?size={size}&offset={offset}", "foo", "bar", "-1", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/api/{namespace}/{extension}/versions?size={size}&offset={offset}", "foo", "bar", "101", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not exceed 100")); + + mockMvc.perform(get("/api/{namespace}/{extension}/versions?size={size}&offset={offset}", "foo", "bar", "100", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + + @Test + void testInvalidGetVersionReferences() throws Exception { + mockMvc.perform(get("/api/{namespace}/{extension}/version-references?size={size}&offset={offset}", "foo", "bar", "-1", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/api/{namespace}/{extension}/version-references?size={size}&offset={offset}", "foo", "bar", "101", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not exceed 100")); + + mockMvc.perform(get("/api/{namespace}/{extension}/version-references?size={size}&offset={offset}", "foo", "bar", "100", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } //---------- UTILITY ----------// diff --git a/server/src/test/java/org/eclipse/openvsx/admin/FileDecisionAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/FileDecisionAPITest.java new file mode 100644 index 000000000..22344351c --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/FileDecisionAPITest.java @@ -0,0 +1,98 @@ +/******************************************************************************** + * 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.admin; + +import io.micrometer.core.instrument.MeterRegistry; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(FileDecisionAPI.class) +@AutoConfigureMockMvc(addFilters = false) +class FileDecisionAPITest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + RepositoryService repositories; + + @MockitoBean + AdminService admins; + + @MockitoBean + MeterRegistry meterRegistry; + + @Test + void getFiles_pagination_validation_failures() throws Exception { + // Always allow the request to pass the admin gate in this test setup. + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform(get("/admin/scans/files") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "-1") + .param("offset", "0") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/admin/scans/files") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "9999") + .param("offset", "0") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be larger than 100")); + + mockMvc.perform(get("/admin/scans/files") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "100") + .param("offset", "-1") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } + + private static class TestData { + static org.eclipse.openvsx.entities.UserData adminUser() { + var user = new org.eclipse.openvsx.entities.UserData(); + user.setRole(org.eclipse.openvsx.entities.UserData.ROLE_ADMIN); + return user; + } + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java index 0d0aef6b7..080a80c4a 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java @@ -12,12 +12,13 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; -import org.eclipse.openvsx.entities.ExtensionScan; -import org.eclipse.openvsx.entities.ExtensionValidationFailure; -import org.eclipse.openvsx.entities.ExtensionVersion; -import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.*; import io.micrometer.core.instrument.MeterRegistry; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.repositories.ScannerJobRepository; +import org.eclipse.openvsx.scanning.ExtensionScanCompletionService; +import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.scanning.ScannerRegistry; import org.eclipse.openvsx.storage.StorageUtilService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; @@ -26,6 +27,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.util.Streamable; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -66,16 +68,61 @@ class ScanAPITest { MeterRegistry meterRegistry; @MockitoBean - org.eclipse.openvsx.scanning.ExtensionScanCompletionService completionService; + ExtensionScanCompletionService completionService; @MockitoBean - org.eclipse.openvsx.scanning.ScannerRegistry scannerRegistry; + ScannerRegistry scannerRegistry; @MockitoBean - org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository; + ScannerJobRepository scanJobRepository; @MockitoBean - org.eclipse.openvsx.scanning.ExtensionScanService scanService; + ExtensionScanService scanService; + + @Test + void getScans_pagination_validation_failures() throws Exception { + // Always allow the request to pass the admin gate in this test setup. + when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + mockMvc.perform(get("/admin/scans") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "-1") + .param("offset", "0") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be negative")); + + mockMvc.perform(get("/admin/scans") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "9999") + .param("offset", "0") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size: parameter must not be larger than 100")); + + mockMvc.perform(get("/admin/scans") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "100") + .param("offset", "-1") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("offset: parameter must not be negative")); + } @Test void getScans_filters_sorting_and_pagination_are_applied() throws Exception { @@ -92,7 +139,7 @@ void getScans_filters_sorting_and_pagination_are_applied() throws Exception { any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any() - )).thenReturn(new PageImpl<>(List.of(scanC), org.springframework.data.domain.PageRequest.of(0, 1), 2)); + )).thenReturn(new PageImpl<>(List.of(scanC), PageRequest.of(0, 1), 2)); when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); @@ -397,8 +444,8 @@ void retryFailedScannerJobs_returns200_andDelegatesToService() throws Exception when(repositories.findExtensionScan(5L)).thenReturn(scan); when(scanService.retryFailedJobs(scan)).thenReturn(scan); when(repositories.findVersion(anyString(), anyString(), anyString(), anyString())).thenReturn(null); - when(repositories.findValidationFailures(any())).thenReturn(org.springframework.data.util.Streamable.empty()); - when(repositories.findExtensionThreats(any())).thenReturn(org.springframework.data.util.Streamable.empty()); + when(repositories.findValidationFailures(any())).thenReturn(Streamable.empty()); + when(repositories.findExtensionThreats(any())).thenReturn(Streamable.empty()); when(storageUtil.getFileUrls(anyList(), anyString(), any(), any())).thenReturn(Map.of()); mockMvc.perform(post("/admin/scans/5/jobs/retry").accept(MediaType.APPLICATION_JSON)) @@ -459,9 +506,9 @@ static ExtensionVersion version(long id, String displayName) { return version; } - static org.eclipse.openvsx.entities.UserData adminUser() { - var user = new org.eclipse.openvsx.entities.UserData(); - user.setRole(org.eclipse.openvsx.entities.UserData.ROLE_ADMIN); + static UserData adminUser() { + var user = new UserData(); + user.setRole(UserData.ROLE_ADMIN); return user; } }