From 5c4fb6e12b3f41589b11a3c9e57ba4810b2c7c86 Mon Sep 17 00:00:00 2001 From: Motoi Okuzono Date: Thu, 22 Oct 2026 18:45:01 +0900 Subject: [PATCH] Add JSON compatibility regression tests --- .../AbstractLineClientExceptionTest.java | 10 + .../ApiClientJsonCompatibilityTest.java | 354 ++++++++++++++++++ .../model/ModelObjectMapperTest.java | 208 ++++++++++ .../JwtSerializationCompatibilityTest.java | 47 +++ .../utils/IntegrationTestSettingsLoader.java | 12 +- .../IntegrationTestSettingsYamlTest.java | 110 ++++++ .../bot/parser/WebhookParserTest.java | 39 ++ .../SampleManageAudienceApplicationTests.java | 27 ++ 8 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/client/ApiClientJsonCompatibilityTest.java create mode 100644 clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/model/ModelObjectMapperTest.java create mode 100644 line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/JwtSerializationCompatibilityTest.java create mode 100644 line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsYamlTest.java diff --git a/clients/line-bot-client-base/src/test/java/com/linecorp/bot/client/base/exception/AbstractLineClientExceptionTest.java b/clients/line-bot-client-base/src/test/java/com/linecorp/bot/client/base/exception/AbstractLineClientExceptionTest.java index a8a273b2a..9eab27203 100644 --- a/clients/line-bot-client-base/src/test/java/com/linecorp/bot/client/base/exception/AbstractLineClientExceptionTest.java +++ b/clients/line-bot-client-base/src/test/java/com/linecorp/bot/client/base/exception/AbstractLineClientExceptionTest.java @@ -27,6 +27,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.linecorp.bot.client.base.LineClientJsonParseException; + import okhttp3.Headers; import okhttp3.Request; import okhttp3.Response; @@ -60,6 +62,14 @@ void constructorWithIoException() { assertThat(exception.getCause()).isEqualTo(ioException); } + @Test + void keepsIOExceptionConstructorSignatures() throws NoSuchMethodException { + assertThat(AbstractLineClientException.class.getConstructor( + Response.class, String.class, IOException.class)).isNotNull(); + assertThat(LineClientJsonParseException.class.getConstructor( + Response.class, IOException.class)).isNotNull(); + } + @Test void constructorNoException() { AbstractLineClientException exception = new AbstractLineClientException(response, "test message"); diff --git a/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/client/ApiClientJsonCompatibilityTest.java b/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/client/ApiClientJsonCompatibilityTest.java new file mode 100644 index 000000000..b10d186a5 --- /dev/null +++ b/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/client/ApiClientJsonCompatibilityTest.java @@ -0,0 +1,354 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.bot.messaging.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Annotation; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.linecorp.bot.client.base.ApiClientBuilder; +import com.linecorp.bot.client.base.BlobContent; +import com.linecorp.bot.client.base.BotAwareJacksonConverter; +import com.linecorp.bot.client.base.LineClientJsonParseException; +import com.linecorp.bot.client.base.Result; +import com.linecorp.bot.client.base.UploadFile; +import com.linecorp.bot.jackson.ModelObjectMapper; +import com.linecorp.bot.messaging.model.ErrorResponse; +import com.linecorp.bot.messaging.model.GetWebhookEndpointResponse; +import com.linecorp.bot.messaging.model.TestWebhookEndpointRequest; +import com.linecorp.bot.messaging.model.TestWebhookEndpointResponse; + +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Okio; +import retrofit2.Converter; +import retrofit2.Retrofit; + +class ApiClientJsonCompatibilityTest { + private static final String WEBHOOK_ENDPOINT_PATH = "/v2/bot/channel/webhook/endpoint"; + + private WireMockServer server; + + @BeforeEach + void startServer() { + server = new WireMockServer(wireMockConfig().dynamicPort()); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(); + } + + @Test + void writesRequestAndIgnoresUnknownResponseProperty() { + server.stubFor(post(urlEqualTo("/v2/bot/channel/webhook/test")).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("X-Line-Request-Id", "request-1") + .withBody("{\"success\":true,\"statusCode\":200,\"futureField\":true}"))); + + Result result = newBuilder().build() + .testWebhookEndpoint( + new TestWebhookEndpointRequest(URI.create("https://example.com"))) + .orTimeout(5, TimeUnit.SECONDS).join(); + + assertThat(result.body().success()).isTrue(); + assertThat(result.body().statusCode()).isEqualTo(200); + assertThat(result.requestId()).isEqualTo("request-1"); + server.verify(postRequestedFor(urlEqualTo("/v2/bot/channel/webhook/test")) + .withRequestBody(equalTo("{\"endpoint\":\"https://example.com\"}"))); + } + + @Test + void rejectsUnknownResponsePropertyWhenStrictParsingEnabled() { + stubWebhookEndpointWithUnknownProperty(); + MessagingApiClient client = newBuilder().failOnUnknownProperties(true).build(); + + assertThatThrownBy(() -> client.getWebhookEndpoint() + .orTimeout(5, TimeUnit.SECONDS).join()) + .hasMessageContaining("futureField"); + } + + @Test + void strictParsingEnabledAfterBuildRejectsUnknownPropertyOnFirstRequest() { + stubWebhookEndpointWithUnknownProperty(); + + ApiClientBuilder builder = newBuilder(); + MessagingApiClient client = builder.build(); + builder.failOnUnknownProperties(true); + + assertThatThrownBy(() -> client.getWebhookEndpoint() + .orTimeout(5, TimeUnit.SECONDS).join()) + .hasMessageContaining("futureField"); + } + + @Test + void strictParsingEnabledAfterFirstRequestStillIgnoresUnknownProperty() { + stubWebhookEndpointWithUnknownProperty(); + + ApiClientBuilder builder = newBuilder(); + MessagingApiClient client = builder.build(); + assertThat(client.getWebhookEndpoint().orTimeout(5, TimeUnit.SECONDS).join().body()) + .isEqualTo(expectedWebhookEndpoint()); + + builder.failOnUnknownProperties(true); + + assertThat(client.getWebhookEndpoint().orTimeout(5, TimeUnit.SECONDS).join().body()) + .isEqualTo(expectedWebhookEndpoint()); + } + + @Test + void mapsJsonErrorResponseToClientException() { + server.stubFor(get(urlEqualTo(WEBHOOK_ENDPOINT_PATH)).willReturn(aResponse() + .withStatus(400) + .withBody("{\"message\":\"request rejected\"}"))); + + assertThatThrownBy(() -> newBuilder().build().getWebhookEndpoint() + .orTimeout(5, TimeUnit.SECONDS).join()) + .hasRootCauseInstanceOf(MessagingApiClientException.class) + .hasMessageContaining("request rejected"); + } + + @Test + void wrapsMalformedErrorResponseInJsonParseException() { + server.stubFor(get(urlEqualTo(WEBHOOK_ENDPOINT_PATH)).willReturn(aResponse() + .withStatus(400) + .withBody("{\"message\":"))); + + Throwable thrown = catchThrowable(() -> newBuilder().build().getWebhookEndpoint() + .orTimeout(5, TimeUnit.SECONDS).join()); + + assertThat(thrown).hasCauseInstanceOf(LineClientJsonParseException.class); + assertThat(thrown.getCause().getCause()).isInstanceOf(IOException.class); + } + + @Test + void reportsMalformedSuccessResponseAsIOException() { + server.stubFor(get(urlEqualTo(WEBHOOK_ENDPOINT_PATH)).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"endpoint\":"))); + + assertThatThrownBy(() -> newBuilder().build().getWebhookEndpoint() + .orTimeout(5, TimeUnit.SECONDS).join()) + .hasCauseInstanceOf(IOException.class); + } + + @Test + void reportsRequestSerializationFailureAsIOException() { + Map payload = new HashMap<>(); + payload.put("self", payload); + Retrofit retrofit = new Retrofit.Builder().baseUrl(server.baseUrl() + "/").build(); + Converter converter = BotAwareJacksonConverter + .create(ModelObjectMapper.createNewObjectMapper()) + .requestBodyConverter(Map.class, new Annotation[0], new Annotation[0], retrofit); + + @SuppressWarnings("unchecked") + Converter objectConverter = + (Converter) converter; + assertThatThrownBy(() -> objectConverter.convert(payload)) + .isInstanceOf(IOException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"", "{\"endpoint\":\""}) + void propagatesOriginalIoExceptionFromResponseConverter(String prefix) { + IOException failure = new SocketTimeoutException("response read timed out"); + Retrofit retrofit = new Retrofit.Builder().baseUrl(server.baseUrl() + "/").build(); + Converter converter = BotAwareJacksonConverter + .create(ModelObjectMapper.createNewObjectMapper()) + .responseBodyConverter(GetWebhookEndpointResponse.class, + new Annotation[0], retrofit); + + assertThatThrownBy(() -> converter.convert(bodyThatFailsOnRead(prefix, failure))) + .isSameAs(failure); + } + + @ParameterizedTest + @ValueSource(strings = {"", "{\"message\":\""}) + void propagatesOriginalIoExceptionFromErrorResponseParsing(String prefix) { + IOException failure = new SocketTimeoutException("error response read timed out"); + try (Response response = errorResponse(bodyThatFailsOnRead(prefix, failure))) { + assertThatThrownBy(() -> new MessagingApiExceptionBuilder().build(response)) + .isSameAs(failure); + } + } + + @Test + void throwsIOExceptionWhenErrorResponseExceedsNestingLimit() { + String json = "{\"future\":" + "[".repeat(1000) + "0" + "]".repeat(1000) + "}"; + + try (Response response = errorResponse( + ResponseBody.create(json, MediaType.get("application/json")))) { + assertThatThrownBy(() -> new MessagingApiExceptionBuilder().build(response)) + .isInstanceOf(IOException.class) + .isNotInstanceOf(LineClientJsonParseException.class); + } + } + + @Test + void wrapsErrorPropertyLengthLimitFailureInJsonParseException() throws IOException { + String json = "{\"message\":\"" + "x".repeat(20_100_000) + "\"}"; + + try (Response response = errorResponse( + ResponseBody.create(json, MediaType.get("application/json")))) { + assertThat(new MessagingApiExceptionBuilder().build(response)) + .isInstanceOf(LineClientJsonParseException.class) + .hasCauseInstanceOf(IOException.class); + } + } + + @ParameterizedTest + @ValueSource(strings = {"{\"message\":", "{\"message\":[]}"}) + void allowsSubclassToCatchInvalidErrorJsonAsIOException(String json) throws IOException { + MessagingApiExceptionBuilder builder = new MessagingApiExceptionBuilder() { + @Override + protected ErrorResponse parseErrorBody(Response response) throws IOException { + try { + return super.parseErrorBody(response); + } catch (IOException e) { + return new ErrorResponse.Builder("fallback error").build(); + } + } + }; + + try (Response response = errorResponse( + ResponseBody.create(json, MediaType.get("application/json")))) { + assertThat(builder.build(response)) + .isInstanceOf(MessagingApiClientException.class) + .hasMessageContaining("fallback error"); + } + } + + @Test + void rejectsNullMapperWhenCreatingConverter() { + assertThatThrownBy(() -> BotAwareJacksonConverter.create(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("mapper == null"); + } + + @Test + void returnsBlobContentWithMimeType() throws Exception { + server.stubFor(get(urlEqualTo("/v2/bot/message/message-1/content")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "image/png") + .withBody("raw content"))); + + BlobContent body = newBlobClient().getMessageContent("message-1") + .orTimeout(5, TimeUnit.SECONDS).join().body(); + + assertThat(body.mimeType()).isEqualTo("image/png"); + assertThat(body.string()).isEqualTo("raw content"); + } + + @Test + void uploadsRawContentWithoutJsonEncoding() { + server.stubFor(post(urlEqualTo("/v2/bot/richmenu/rich-menu-1/content")) + .willReturn(aResponse().withStatus(200))); + byte[] content = "raw content".getBytes(StandardCharsets.UTF_8); + + newBlobClient().setRichMenuImage( + "rich-menu-1", UploadFile.fromByteArray(content, "text/plain")) + .orTimeout(5, TimeUnit.SECONDS).join(); + + server.verify(postRequestedFor(urlEqualTo("/v2/bot/richmenu/rich-menu-1/content")) + .withRequestBody(equalTo("raw content"))); + } + + private void stubWebhookEndpointWithUnknownProperty() { + server.stubFor(get(urlEqualTo(WEBHOOK_ENDPOINT_PATH)).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"endpoint\":\"https://example.com\"," + + "\"active\":true,\"futureField\":true}"))); + } + + private ResponseBody bodyThatFailsOnRead(String prefix, IOException failure) { + InputStream input = new InputStream() { + private final InputStream initialBytes = + new ByteArrayInputStream(prefix.getBytes(StandardCharsets.UTF_8)); + + @Override + public int read() throws IOException { + int value = initialBytes.read(); + if (value != -1) { + return value; + } + throw failure; + } + }; + return ResponseBody.create(Okio.buffer(Okio.source(input)), + MediaType.get("application/json"), -1); + } + + private Response errorResponse(ResponseBody body) { + return new Response.Builder() + .request(new Request.Builder().url(server.baseUrl()).build()) + .protocol(Protocol.HTTP_1_1) + .code(400) + .message("Bad Request") + .body(body) + .build(); + } + + private GetWebhookEndpointResponse expectedWebhookEndpoint() { + return new GetWebhookEndpointResponse(URI.create("https://example.com"), true); + } + + private ApiClientBuilder newBuilder() { + return MessagingApiClient.builder("token") + .apiEndPoint(URI.create(server.baseUrl())); + } + + private MessagingApiBlobClient newBlobClient() { + return MessagingApiBlobClient.builder("token") + .apiEndPoint(URI.create(server.baseUrl())) + .build(); + } +} diff --git a/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/model/ModelObjectMapperTest.java b/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/model/ModelObjectMapperTest.java new file mode 100644 index 000000000..4113438d4 --- /dev/null +++ b/clients/line-bot-messaging-api-client/src/test/java/com/linecorp/bot/messaging/model/ModelObjectMapperTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.bot.messaging.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.SerializationFeature; + +import com.linecorp.bot.jackson.ModelObjectMapper; + +class ModelObjectMapperTest { + @Test + void ignoresUnknownProperties() throws Exception { + GetWebhookEndpointResponse response = ModelObjectMapper.createNewObjectMapper() + .readValue( + "{\"endpoint\":\"https://example.com\"," + + "\"active\":true,\"newField\":true}", + GetWebhookEndpointResponse.class); + + assertThat(response).isEqualTo( + new GetWebhookEndpointResponse(URI.create("https://example.com"), true)); + } + + @Test + void mapsUnknownQuotaTypeToUndefined() throws Exception { + MessageQuotaResponse response = ModelObjectMapper.createNewObjectMapper() + .readValue("{\"type\":\"future\"}", MessageQuotaResponse.class); + + assertThat(response.type()).isEqualTo(QuotaType.UNDEFINED); + } + + @Test + void readsNumericTimestampAsMilliseconds() throws Exception { + Instant timestamp = ModelObjectMapper.createNewObjectMapper() + .readValue("1462629479859", Instant.class); + + assertThat(timestamp).isEqualTo(Instant.parse("2016-05-07T13:57:59.859Z")); + } + + @Test + void readsIsoTimestamp() throws Exception { + Instant timestamp = ModelObjectMapper.createNewObjectMapper() + .readValue("\"2016-05-07T13:57:59.859Z\"", Instant.class); + + assertThat(timestamp).isEqualTo(Instant.parse("2016-05-07T13:57:59.859Z")); + } + + @Test + void writesTimestampWithFractionalSeconds() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writeValueAsString(Instant.parse("2016-05-07T13:57:59.859Z")); + + assertThat(json).isEqualTo("1462629479.859000000"); + } + + @Test + void readsOffsetDateTimeInGeneratedResponse() throws Exception { + TestWebhookEndpointResponse response = ModelObjectMapper.createNewObjectMapper() + .readValue("{\"timestamp\":\"2020-12-03T10:15:30.121Z\"," + + "\"statusCode\":200}", + TestWebhookEndpointResponse.class); + + assertThat(response.timestamp()) + .isEqualTo(OffsetDateTime.parse("2020-12-03T10:15:30.121Z")); + } + + @Test + void writesOffsetDateTimeWithFractionalSeconds() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writeValueAsString(OffsetDateTime.parse("1970-01-01T00:00:01.125Z")); + + assertThat(json).isEqualTo("1.125000000"); + } + + @Test + void readsNullPrimitiveArrayElementAsDefaultValue() throws Exception { + boolean[] values = ModelObjectMapper.createNewObjectMapper() + .readValue("[null]", boolean[].class); + + assertThat(values).containsExactly(false); + } + + @Test + void writesDurationAsNumericTimestamp() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writeValueAsString(Duration.ofMillis(1250)); + + assertThat(json).isEqualTo("1.250000000"); + } + + @Test + void readsAndWritesSixHundredNestedArrays() throws Exception { + String json = "[".repeat(600) + "0" + "]".repeat(600); + var mapper = ModelObjectMapper.createNewObjectMapper(); + + Object value = mapper.readValue(json, Object.class); + + assertThat(mapper.writeValueAsString(value)).isEqualTo(json); + } + + @Test + void rejectsStringLongerThanTwentyMillionCharacters() { + String json = "\"" + "x".repeat(20_100_000) + "\""; + var mapper = ModelObjectMapper.createNewObjectMapper(); + + assertThatThrownBy(() -> mapper.readValue(json, String.class)) + .hasMessageContaining("String value length"); + } + + @Test + void includesUnannotatedPropertyWhenWritingWithView() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writerWithView(SelectedView.class) + .writeValueAsString(new ViewPayload("selected", "common")); + + assertThat(json).isEqualTo("{\"selected\":\"selected\",\"common\":\"common\"}"); + } + + @Test + void ignoresTrailingClosingBraceAfterJsonObject() throws Exception { + byte[] body = ("{\"endpoint\":\"https://example.com\",\"active\":true}}") + .getBytes(StandardCharsets.UTF_8); + GetWebhookEndpointResponse response = ModelObjectMapper.createNewObjectMapper() + .readValue(new ByteArrayInputStream(body), GetWebhookEndpointResponse.class); + + assertThat(response).isEqualTo( + new GetWebhookEndpointResponse(URI.create("https://example.com"), true)); + } + + @Test + void usesLastValueForDuplicateMapKey() throws Exception { + var values = ModelObjectMapper.createNewObjectMapper() + .readValue("{\"value\":1,\"value\":2}", Map.class); + + assertThat(values.get("value")).isEqualTo(2); + } + + @Test + void omitsVoidGetterWhenWritingJson() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writeValueAsString(new VoidGetterPayload("value")); + + assertThat(json).isEqualTo("{\"value\":\"value\"}"); + } + + @Test + void rejectsNonComparableKeysWhenMapSortingIsEnabled() { + Map values = Map.of(new Object(), "value"); + var writer = ModelObjectMapper.createNewObjectMapper().writer() + .with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + + assertThatThrownBy(() -> writer.writeValueAsString(values)) + .hasMessageContaining("Cannot order Map entries"); + } + + @Test + void writesWebhookEndpointRequestAsJson() throws Exception { + String json = ModelObjectMapper.createNewObjectMapper() + .writeValueAsString( + new SetWebhookEndpointRequest(URI.create("https://example.com"))); + + assertThat(json).isEqualTo("{\"endpoint\":\"https://example.com\"}"); + } + + @Test + void returnsSeparateMappers() { + assertThat(ModelObjectMapper.createNewObjectMapper()) + .isNotSameAs(ModelObjectMapper.createNewObjectMapper()); + } + + private interface SelectedView { + } + + private record ViewPayload(@JsonView(SelectedView.class) String selected, String common) { + } + + private record VoidGetterPayload(String value) { + public Void getAbsent() { + return null; + } + } +} diff --git a/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/JwtSerializationCompatibilityTest.java b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/JwtSerializationCompatibilityTest.java new file mode 100644 index 000000000..3a3d472da --- /dev/null +++ b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/JwtSerializationCompatibilityTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.bot.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.Test; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; + +class JwtSerializationCompatibilityTest { + @Test + void roundTripsJwtClaims() { + SecretKey key = new SecretKeySpec(new byte[32], "HmacSHA256"); + + String token = Jwts.builder() + .subject("bot") + .claim("scope", List.of("reply", "push")) + .signWith(key) + .compact(); + Claims claims = Jwts.parser().verifyWith(key).build() + .parseSignedClaims(token).getPayload(); + + assertThat(claims.getSubject()).isEqualTo("bot"); + assertThat(claims.get("scope")).isEqualTo(List.of("reply", "push")); + } +} diff --git a/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsLoader.java b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsLoader.java index 5114fb4a2..153d135e8 100644 --- a/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsLoader.java +++ b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsLoader.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; +import java.io.InputStream; import java.net.URL; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,8 +35,10 @@ public static IntegrationTestSettings load() throws IOException { .as("exists integration_test_settings.yml in resource directory") .isNotNull(); - IntegrationTestSettings settings = new ObjectMapper(new YAMLFactory()) - .readValue(TEST_RESOURCE.openStream(), IntegrationTestSettings.class); + IntegrationTestSettings settings; + try (InputStream yaml = TEST_RESOURCE.openStream()) { + settings = readYamlSettings(yaml); + } assumeThat(settings.enabled() != null && !settings.enabled()) .as("Integration test was disabled.") @@ -43,4 +46,9 @@ public static IntegrationTestSettings load() throws IOException { return settings; } + + static IntegrationTestSettings readYamlSettings(InputStream yaml) throws IOException { + return new ObjectMapper(new YAMLFactory()) + .readValue(yaml, IntegrationTestSettings.class); + } } diff --git a/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsYamlTest.java b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsYamlTest.java new file mode 100644 index 000000000..805446ada --- /dev/null +++ b/line-bot-integration-test/src/integrationTest/java/com/linecorp/bot/client/utils/IntegrationTestSettingsYamlTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.bot.client.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class IntegrationTestSettingsYamlTest { + @Test + void readsSettingsFromYaml() throws Exception { + String yaml = """ + enabled: true + token: "token:abc" + apiEndpoint: https://example.com + userId: U123 + audienceIfas: + - first + - second + failOnUnknownProperties: false + """; + + IntegrationTestSettings settings = IntegrationTestSettingsLoader.readYamlSettings( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))); + + assertThat(settings.enabled()).isTrue(); + assertThat(settings.token()).isEqualTo("token:abc"); + assertThat(settings.apiEndpoint()).isEqualTo("https://example.com"); + assertThat(settings.userId()).isEqualTo("U123"); + assertThat(settings.audienceIfas()).containsExactly("first", "second"); + assertThat(settings.failOnUnknownProperties()).isFalse(); + } + + @Test + void reportsMalformedYamlAsIOException() { + byte[] yaml = "enabled: [unterminated".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> IntegrationTestSettingsLoader.readYamlSettings( + new ByteArrayInputStream(yaml))) + .isInstanceOf(IOException.class); + } + + @Test + void rejectsUnknownSettingAsIOException() { + byte[] yaml = "enabled: true\nunknownSetting: true\n".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> IntegrationTestSettingsLoader.readYamlSettings( + new ByteArrayInputStream(yaml))) + .isInstanceOf(IOException.class) + .hasMessageContaining("unknownSetting"); + } + + @ParameterizedTest + @ValueSource(strings = {"enabled: true\n", "enabled: true\nfailOnUnknownProperties: null\n"}) + void defaultsMissingOrNullPrimitiveSettingToFalse(String yaml) throws IOException { + IntegrationTestSettings settings = IntegrationTestSettingsLoader.readYamlSettings( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))); + + assertThat(settings.failOnUnknownProperties()).isFalse(); + } + + @Test + void readsOnlyFirstYamlDocument() throws IOException { + byte[] yaml = "enabled: true\n---\nenabled: false\n".getBytes(StandardCharsets.UTF_8); + + IntegrationTestSettings settings = IntegrationTestSettingsLoader.readYamlSettings( + new ByteArrayInputStream(yaml)); + + assertThat(settings.enabled()).isTrue(); + } + + @Test + void reportsYamlReadFailureAsIOExceptionWithOriginalRootCause() throws IOException { + IOException failure = new SocketTimeoutException("settings read timed out"); + try (InputStream yaml = new InputStream() { + @Override + public int read() throws IOException { + throw failure; + } + }) { + assertThatThrownBy(() -> IntegrationTestSettingsLoader.readYamlSettings(yaml)) + .isInstanceOf(IOException.class) + .rootCause() + .isSameAs(failure); + } + } +} diff --git a/line-bot-parser/src/test/java/com/linecorp/bot/parser/WebhookParserTest.java b/line-bot-parser/src/test/java/com/linecorp/bot/parser/WebhookParserTest.java index 69efe2b70..a2159637d 100644 --- a/line-bot-parser/src/test/java/com/linecorp/bot/parser/WebhookParserTest.java +++ b/line-bot-parser/src/test/java/com/linecorp/bot/parser/WebhookParserTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -166,4 +167,42 @@ public void testWithoutSkipSignatureVerificationSupplierInConstructor() throws E assertThat(messageEvent.timestamp()).isEqualTo( Instant.parse("2016-05-07T13:57:59.859Z").toEpochMilli()); } + + @Test + public void testIgnoresUnknownCallbackField() throws Exception { + byte[] payload = """ + {"destination":"U111","events":[],"futureField":true} + """.getBytes(StandardCharsets.UTF_8); + WebhookParser parser = new WebhookParser( + signatureValidator, FixedSkipSignatureVerificationSupplier.of(true)); + + CallbackRequest callback = parser.handle("SSSSIGNATURE", payload); + + assertThat(callback.destination()).isEqualTo("U111"); + assertThat(callback.events()).isEmpty(); + } + + @Test + public void testIgnoresTrailingClosingBraceAfterCallbackJson() throws Exception { + byte[] payload = """ + {"destination":"U111","events":[]}} + """.getBytes(StandardCharsets.UTF_8); + WebhookParser parser = new WebhookParser( + signatureValidator, FixedSkipSignatureVerificationSupplier.of(true)); + + CallbackRequest callback = parser.handle("SSSSIGNATURE", payload); + + assertThat(callback.destination()).isEqualTo("U111"); + assertThat(callback.events()).isEmpty(); + } + + @Test + public void testMalformedCallbackJsonThrowsIOException() { + byte[] payload = "{\"destination\":".getBytes(StandardCharsets.UTF_8); + WebhookParser parser = new WebhookParser( + signatureValidator, FixedSkipSignatureVerificationSupplier.of(true)); + + assertThatThrownBy(() -> parser.handle("SSSSIGNATURE", payload)) + .isInstanceOf(IOException.class); + } } diff --git a/samples/sample-manage-audience/src/test/java/com/linecorp/bot/messagingapidemoapp/SampleManageAudienceApplicationTests.java b/samples/sample-manage-audience/src/test/java/com/linecorp/bot/messagingapidemoapp/SampleManageAudienceApplicationTests.java index e3644ea4c..1bd84503b 100644 --- a/samples/sample-manage-audience/src/test/java/com/linecorp/bot/messagingapidemoapp/SampleManageAudienceApplicationTests.java +++ b/samples/sample-manage-audience/src/test/java/com/linecorp/bot/messagingapidemoapp/SampleManageAudienceApplicationTests.java @@ -16,15 +16,42 @@ package com.linecorp.bot.messagingapidemoapp; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import com.linecorp.bot.messaging.model.FlexBox; +import com.linecorp.bot.messaging.model.FlexBubble; +import com.linecorp.bot.messaging.model.FlexMessage; +import com.linecorp.bot.messagingapidemoapp.controller.message.MessageHelper; + @SpringBootTest(properties = { "line.bot.channel-token=DUMMY", "line.bot.channel-secret=DUMMY", }) class SampleManageAudienceApplicationTests { + @Autowired + private MessageHelper messageHelper; + @Test void contextLoads() { } + + @Test + void mapsUnknownFlexLayoutToUndefined() { + String json = """ + {"type":"flex","altText":"example","contents":{ + "type":"bubble","body":{"type":"box","layout":"future","contents":[]} + }} + """; + + var messages = messageHelper.buildMessages(new String[] {json}); + + assertThat(messages).hasSize(1); + FlexMessage message = (FlexMessage) messages.get(0); + FlexBubble bubble = (FlexBubble) message.contents(); + assertThat(bubble.body().layout()).isEqualTo(FlexBox.Layout.UNDEFINED); + } }