diff --git a/server/src/main/java/org/eclipse/openvsx/web/JacksonConfig.java b/server/src/main/java/org/eclipse/openvsx/web/JacksonConfig.java new file mode 100644 index 000000000..1f6b7b3f9 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/web/JacksonConfig.java @@ -0,0 +1,35 @@ +/******************************************************************************** + * 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 org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.DeserializationFeature; + +@Configuration +public class JacksonConfig { + + // Jackson 3 (used by Spring Boot 4 for @RequestBody handling) flipped the default of + // FAIL_ON_NULL_FOR_PRIMITIVES from false to true, unlike the Jackson 2 engine this app ran + // on under Spring Boot 3. Several request DTOs have primitive int/long/boolean fields - + // e.g. ExtensionQueryParam (the VS Code gallery query protocol, which this app does not + // control the shape of) and ReviewJson.rating - that used to silently accept an explicit + // JSON null for such a field (coercing it to 0/false) and now reject the whole request + // with a 400 instead. Restore the previous, permissive behavior globally rather than + // patching every affected DTO one by one. + @Bean + public JsonMapperBuilderCustomizer jsonMapperBuilderCustomizer() { + return builder -> builder.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigDtoTest.java b/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigDtoTest.java new file mode 100644 index 000000000..6af900f03 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigDtoTest.java @@ -0,0 +1,99 @@ +/******************************************************************************** + * 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 java.util.stream.Stream; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.adapter.ExtensionQueryParam; +import org.eclipse.openvsx.json.ChangeNamespaceJson; +import org.eclipse.openvsx.json.CustomerJson; +import org.eclipse.openvsx.json.NamespaceDetailsJson; +import org.eclipse.openvsx.json.QueryParamJson; +import org.eclipse.openvsx.json.ReviewJson; +import org.eclipse.openvsx.json.SettingsJson; +import org.eclipse.openvsx.json.TierJson; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the {@code JsonMapper} bean Spring actually wires into {@code @RequestBody} + * handling - not a hand-built one - carries {@link JacksonConfig}'s customizer, for every + * request DTO found to have a primitive int/long/boolean field reachable from a client. A + * client sending an explicit JSON {@code null} for any of these must not 400. + */ +@JsonTest +@Import(JacksonConfig.class) +@MockitoBean(types = SimpleMeterRegistry.class) +class JacksonConfigDtoTest { + + @Autowired + JsonMapper objectMapper; + + static Stream affectedDtos() { + return Stream.of( + // POST /api/{namespace}/{extension}/review + Arguments.of("{\"rating\": null}", ReviewJson.class), + // POST /api/-/query (deprecated but still live) + Arguments.of("{\"includeAllVersions\": null}", QueryParamJson.class), + // AdminAPI#updateSettings + Arguments.of("{\"readOnly\": null}", SettingsJson.class), + // AdminAPI#changeNamespace + Arguments.of( + "{\"removeOldNamespace\": null, \"mergeIfNewNamespaceAlreadyExists\": null}", + ChangeNamespaceJson.class), + // UserAPI#updateNamespaceDetails + Arguments.of("{\"verified\": null}", NamespaceDetailsJson.class), + // RateLimitAPI#createTier / #updateTier + Arguments.of("{\"capacity\": null, \"duration\": null}", TierJson.class), + // RateLimitAPI#createCustomer / #updateCustomer - primitives nested under .tier + Arguments.of( + "{\"tier\": {\"capacity\": null, \"duration\": null}}", + CustomerJson.class), + // VSCodeAPI#extensionQuery - the VS Code gallery query protocol; this app does + // not control the shape of what a VS Code/VSCodium client sends here + Arguments.of("{\"flags\": null}", ExtensionQueryParam.class), + Arguments.of( + "{\"filters\": [{\"pageNumber\": null, \"pageSize\": null, \"sortBy\": null, \"sortOrder\": null}]}", + ExtensionQueryParam.class), + Arguments.of( + "{\"filters\": [{\"criteria\": [{\"filterType\": null}]}]}", + ExtensionQueryParam.class)); + } + + @ParameterizedTest + @MethodSource("affectedDtos") + void requestBodyJsonMapperAcceptsAnExplicitNullForPrimitiveFields(String json, Class dtoType) { + assertThat(objectMapper.readValue(json, dtoType)).isNotNull(); + } + + // Sanity check that the fix is actually exercised above, not a mapper that would have + // accepted the null anyway: the review's rating still ends up coerced to its primitive + // default rather than, say, silently dropping the field. + @Test + void nullPrimitiveIsCoercedToItsDefaultValue() { + var review = objectMapper.readValue("{\"rating\": null}", ReviewJson.class); + + assertThat(review.getRating()).isZero(); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigTest.java b/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigTest.java new file mode 100644 index 000000000..e6dd12cde --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/web/JacksonConfigTest.java @@ -0,0 +1,55 @@ +/******************************************************************************** + * 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 org.junit.jupiter.api.Test; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; + +import org.eclipse.openvsx.json.ReviewJson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JacksonConfigTest { + + private static final String REVIEW_WITH_NULL_RATING = "{\"rating\": null}"; + + // Sanity check documenting the Jackson 3 default this config exists to work around: an + // explicit JSON null for a primitive field is rejected, whereas the Jackson 2 engine this + // app ran on under Spring Boot 3 silently coerced it to the primitive's default value. + @Test + void jackson3DefaultRejectsNullForPrimitiveFields() { + var mapper = JsonMapper.builder().build(); + + assertThatThrownBy(() -> mapper.readValue(REVIEW_WITH_NULL_RATING, ReviewJson.class)) + .isInstanceOf(MismatchedInputException.class) + .hasMessageContaining("FAIL_ON_NULL_FOR_PRIMITIVES"); + } + + // Regression: several request DTOs reachable from a client - most notably + // ExtensionQueryParam, the VS Code gallery query protocol this app does not control the + // shape of - have primitive int/long/boolean fields that used to tolerate an explicit + // JSON null. Without this customizer, such a request now fails with a 400 instead of + // being accepted like it was under Spring Boot 3. + @Test + void customizerRestoresTheOldPermissiveBehavior() { + var builder = JsonMapper.builder(); + new JacksonConfig().jsonMapperBuilderCustomizer().customize(builder); + var mapper = builder.build(); + + var review = mapper.readValue(REVIEW_WITH_NULL_RATING, ReviewJson.class); + + assertThat(review.getRating()).isZero(); + } +}