diff --git a/allure-grpc/README.md b/allure-grpc/README.md index d826d1ae..52d20557 100644 --- a/allure-grpc/README.md +++ b/allure-grpc/README.md @@ -8,7 +8,7 @@ Use this module when your tests call gRPC services and you want method calls, me - Allure Java 3.x requires Java 17 or newer. - This module targets gRPC Java. -- The current build validates against gRPC Java 1.81.0 and Protobuf Java 4.35.0. +- The current build validates against gRPC Java 1.83.1 and Protobuf Java 4.35.1. ## Installation @@ -42,19 +42,38 @@ ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080) .build(); ``` -For advanced capture policy, use the constructor that accepts an HTTP exchange builder customizer: +Request and response metadata are captured by default. Cookie metadata is represented as structured HTTP exchange +cookies, so header and cookie redaction can be configured through the HTTP exchange capture policy. + +Use the builder to add application-specific header and cookie redaction: + +```java +ClientInterceptor allure = AllureGrpc.builder() + .redactHeader("x-api-key") + .redactCookie("session") + .build(); +``` + +Metadata capture can be disabled independently for either direction: ```java -ClientInterceptor allure = new AllureGrpc( - Allure.getLifecycle(), - true, - true, - exchange -> exchange.redactHeader("authorization") -); +ClientInterceptor allure = AllureGrpc.builder() + .captureRequestMetadata(false) + .captureResponseMetadata(false) + .build(); +``` + +For other HTTP exchange capture options, configure the underlying exchange builder: + +```java +ClientInterceptor allure = AllureGrpc.builder() + .configureExchange(exchange -> exchange.setMaxBodySize(256_000)) + .build(); ``` ## Report Output - gRPC method calls as Allure steps. - Request and response messages, metadata, status, and timing. +- Repeated metadata values in their original order; binary metadata values are Base64-encoded. - Stream metadata for unary and streaming calls where available. diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index f302bb31..2b44f769 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -18,10 +18,12 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageOrBuilder; import com.google.protobuf.util.JsonFormat; +import io.grpc.Attributes; import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.ClientCall; import io.grpc.ClientInterceptor; +import io.grpc.ClientStreamTracer; import io.grpc.ForwardingClientCall; import io.grpc.ForwardingClientCallListener; import io.grpc.Metadata; @@ -45,12 +47,12 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Base64; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; /** @@ -64,7 +66,8 @@ "checkstyle:ClassFanOutComplexity", "checkstyle:AnonInnerLength", "checkstyle:JavaNCSS", - "PMD.GodClass" + "PMD.GodClass", + "PMD.TooManyMethods" } ) public class AllureGrpc implements ClientInterceptor { @@ -76,6 +79,8 @@ public class AllureGrpc implements ClientInterceptor { private static final String GRPC_STATUS = "grpc-status"; private static final String GRPC_MESSAGE = "grpc-message"; private static final String CONTENT_TYPE_HEADER = "content-type"; + private static final String TE_HEADER = "te"; + private static final String AUTHORITY_HEADER = ":authority"; private static final String HTTP_METHOD = "POST"; private static final String HTTP_VERSION = "HTTP/2"; private static final String PATH_SEPARATOR = "/"; @@ -84,14 +89,24 @@ public class AllureGrpc implements ClientInterceptor { private final AllureLifecycle lifecycle; private final boolean markStepFailedOnNonZeroCode; - private final boolean interceptResponseMetadata; + private final boolean captureRequestMetadata; + private final boolean captureResponseMetadata; private final Consumer exchangeCustomizer; /** - * Creates an Allure grpc with default configuration. + * Creates an Allure gRPC interceptor that captures request and response metadata. */ public AllureGrpc() { - this(Allure.getLifecycle(), true, false); + this(builder()); + } + + /** + * Creates an Allure gRPC builder. + * + * @return a builder configured to capture request and response metadata + */ + public static Builder builder() { + return new Builder(); } /** @@ -99,13 +114,13 @@ public AllureGrpc() { * * @param lifecycle the Allure lifecycle to use * @param markStepFailedOnNonZeroCode the mark step failed on non zero code - * @param interceptResponseMetadata the intercept response metadata + * @param interceptResponseMetadata whether to capture response metadata; request metadata is captured */ public AllureGrpc( final AllureLifecycle lifecycle, final boolean markStepFailedOnNonZeroCode, final boolean interceptResponseMetadata) { - this(lifecycle, markStepFailedOnNonZeroCode, interceptResponseMetadata, builder -> { + this(lifecycle, markStepFailedOnNonZeroCode, true, interceptResponseMetadata, builder -> { }); } @@ -114,7 +129,7 @@ public AllureGrpc( * * @param lifecycle the Allure lifecycle to use * @param markStepFailedOnNonZeroCode the mark step failed on non zero code - * @param interceptResponseMetadata the intercept response metadata + * @param interceptResponseMetadata whether to capture response metadata; request metadata is captured * @param exchangeCustomizer the HTTP exchange builder customizer */ public AllureGrpc( @@ -122,9 +137,29 @@ public AllureGrpc( final boolean markStepFailedOnNonZeroCode, final boolean interceptResponseMetadata, final Consumer exchangeCustomizer) { + this(lifecycle, markStepFailedOnNonZeroCode, true, interceptResponseMetadata, exchangeCustomizer); + } + + private AllureGrpc(final Builder builder) { + this( + builder.lifecycle, + builder.markStepFailedOnNonZeroCode, + builder.captureRequestMetadata, + builder.captureResponseMetadata, + builder.exchangeCustomizer + ); + } + + private AllureGrpc( + final AllureLifecycle lifecycle, + final boolean markStepFailedOnNonZeroCode, + final boolean captureRequestMetadata, + final boolean captureResponseMetadata, + final Consumer exchangeCustomizer) { this.lifecycle = lifecycle; this.markStepFailedOnNonZeroCode = markStepFailedOnNonZeroCode; - this.interceptResponseMetadata = interceptResponseMetadata; + this.captureRequestMetadata = captureRequestMetadata; + this.captureResponseMetadata = captureResponseMetadata; this.exchangeCustomizer = exchangeCustomizer == null ? builder -> { } : exchangeCustomizer; } @@ -148,8 +183,8 @@ public ClientCall interceptCall( final long start = System.currentTimeMillis(); final List clientMessages = new ArrayList<>(); final List serverMessages = new ArrayList<>(); - final Map initialHeaders = new LinkedHashMap<>(); - final Map trailers = new LinkedHashMap<>(); + final List initialHeaders = new ArrayList<>(); + final List trailers = new ArrayList<>(); final String authority = channel.authority(); final String stepName = buildStepName(channel, methodDescriptor); @@ -162,11 +197,16 @@ public ClientCall interceptCall( serverMessages, initialHeaders, trailers, authority, start ); + final CallOptions effectiveCallOptions = captureRequestMetadata + ? callOptions.withStreamTracerFactory(requestMetadataTracer(stepContext)) + : callOptions; + return new ForwardingClientCall.SimpleForwardingClientCall( - channel.newCall(methodDescriptor, callOptions) + channel.newCall(methodDescriptor, effectiveCallOptions) ) { @Override public void start(final Listener responseListener, final Metadata requestHeaders) { + handleRequestHeaders(requestHeaders, stepContext); final Listener forwardingListener = new ForwardingClientCallListener() { @Override protected Listener delegate() { @@ -207,8 +247,8 @@ private void handleClose( final Metadata responseTrailers, final StepContext stepContext) { try { - if (interceptResponseMetadata && responseTrailers != null) { - copyAsciiResponseMetadata(responseTrailers, stepContext.getTrailers()); + if (captureResponseMetadata && responseTrailers != null) { + stepContext.getTrailers().addAll(copyMetadata(responseTrailers)); } attachExchange(stepContext, status); stepContext.getLifecycle().updateStep( @@ -226,13 +266,45 @@ private void handleClose( } } - private void handleHeaders(final Metadata headers, final Map destination) { + private ClientStreamTracer.Factory requestMetadataTracer(final StepContext stepContext) { + return new ClientStreamTracer.Factory() { + @Override + public ClientStreamTracer newClientStreamTracer( + final ClientStreamTracer.StreamInfo info, + final Metadata headers) { + handleRequestHeaders(headers, stepContext); + return new ClientStreamTracer() { + @Override + public void streamCreated(final Attributes transportAttrs, final Metadata streamHeaders) { + handleRequestHeaders(streamHeaders, stepContext); + } + + @Override + public void streamClosed(final io.grpc.Status status) { + handleRequestHeaders(headers, stepContext); + } + }; + } + }; + } + + private void handleRequestHeaders(final Metadata headers, final StepContext stepContext) { + try { + if (captureRequestMetadata && headers != null) { + stepContext.setRequestHeaders(copyMetadata(headers)); + } + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture request metadata", throwable); + } + } + + private void handleHeaders(final Metadata headers, final List destination) { try { - if (interceptResponseMetadata && headers != null) { - copyAsciiResponseMetadata(headers, destination); + if (captureResponseMetadata && headers != null) { + destination.addAll(copyMetadata(headers)); } } catch (Throwable throwable) { - LOGGER.warn("Failed to capture response headers", throwable); + LOGGER.warn("Failed to capture response metadata", throwable); } } @@ -260,6 +332,7 @@ private void attachExchange(final StepContext stepContext, final io.grpc.S final HttpExchangeRequest request = buildRequest( stepContext.getMethodDescriptor(), stepContext.getClientMessages(), + stepContext.getRequestHeaders(), stepContext.getAuthority() ); final HttpExchangeResponse response = buildResponse( @@ -286,16 +359,24 @@ private HttpExchange.Builder exchangeBuilder(final HttpExchangeRequest request) private HttpExchangeRequest buildRequest( final MethodDescriptor methodDescriptor, final List clientMessages, + final List requestHeaders, final String authority) { final HttpExchangeRequest.Builder builder = HttpExchangeRequest.builder( HTTP_METHOD, PATH_SEPARATOR + methodDescriptor.getFullMethodName() ) - .setHttpVersion(HTTP_VERSION) - .addHeader(CONTENT_TYPE_HEADER, GRPC_CONTENT_TYPE) - .addHeader("te", "trailers"); - if (authority != null) { - builder.addHeader(":authority", authority); + .setHttpVersion(HTTP_VERSION); + if (!containsName(requestHeaders, CONTENT_TYPE_HEADER)) { + builder.addHeader(CONTENT_TYPE_HEADER, GRPC_CONTENT_TYPE); + } + if (!containsName(requestHeaders, TE_HEADER)) { + builder.addHeader(TE_HEADER, "trailers"); + } + if (authority != null && !containsName(requestHeaders, AUTHORITY_HEADER)) { + builder.addHeader(AUTHORITY_HEADER, authority); + } + if (captureRequestMetadata) { + builder.addHeaders(requestHeaders); } return builder .setBody(toHttpBody(clientMessages, isRequestStreaming(methodDescriptor.getType()))) @@ -306,27 +387,25 @@ private HttpExchangeResponse buildResponse( final MethodDescriptor methodDescriptor, final List serverMessages, final io.grpc.Status status, - final Map initialHeaders, - final Map trailers) { - final Map responseHeaders = new LinkedHashMap<>(); - responseHeaders.put(CONTENT_TYPE_HEADER, GRPC_CONTENT_TYPE); - if (interceptResponseMetadata) { - responseHeaders.putAll(initialHeaders); - } - - final Map responseTrailers = new LinkedHashMap<>(); - if (interceptResponseMetadata) { - responseTrailers.putAll(trailers); - } - responseTrailers.putIfAbsent(GRPC_STATUS, String.valueOf(status.getCode().value())); - responseTrailers.putIfAbsent(GRPC_MESSAGE, status.getDescription() == null ? "" : status.getDescription()); - + final List initialHeaders, + final List trailers) { final HttpExchangeResponse.Builder builder = HttpExchangeResponse.builder() .setStatus(200) .setHttpVersion(HTTP_VERSION) - .addHeaders(toNameValues(responseHeaders)) .setBody(toHttpBody(serverMessages, isResponseStreaming(methodDescriptor.getType()))); - responseTrailers.forEach(builder::addTrailer); + if (!containsName(initialHeaders, CONTENT_TYPE_HEADER)) { + builder.addHeader(CONTENT_TYPE_HEADER, GRPC_CONTENT_TYPE); + } + if (captureResponseMetadata) { + builder.addHeaders(initialHeaders); + trailers.forEach(trailer -> builder.addTrailer(trailer.name(), trailer.value())); + } + if (!containsName(trailers, GRPC_STATUS)) { + builder.addTrailer(GRPC_STATUS, String.valueOf(status.getCode().value())); + } + if (!containsName(trailers, GRPC_MESSAGE)) { + builder.addTrailer(GRPC_MESSAGE, status.getDescription() == null ? "" : status.getDescription()); + } return builder.build(); } @@ -409,12 +488,6 @@ private static HttpExchangeBody toHttpBody(final List messages, final bo ); } - private static List toNameValues(final Map values) { - return values.entrySet().stream() - .map(entry -> new HttpExchangeNameValue(entry.getKey(), entry.getValue())) - .toList(); - } - private static boolean isRequestStreaming(final MethodDescriptor.MethodType methodType) { return methodType == MethodDescriptor.MethodType.CLIENT_STREAMING || methodType == MethodDescriptor.MethodType.BIDI_STREAMING; @@ -425,21 +498,157 @@ private static boolean isResponseStreaming(final MethodDescriptor.MethodType met || methodType == MethodDescriptor.MethodType.BIDI_STREAMING; } - private static void copyAsciiResponseMetadata( - final Metadata source, - final Map target) { + private static List copyMetadata(final Metadata source) { + final List result = new ArrayList<>(); for (String key : source.keys()) { if (key == null) { continue; } if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { - continue; + copyBinaryMetadataValues(source, key, result); + } else { + copyAsciiMetadataValues(source, key, result); + } + } + return List.copyOf(result); + } + + private static void copyAsciiMetadataValues( + final Metadata source, + final String key, + final List destination) { + try { + final Metadata.Key metadataKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + final Iterable values = source.getAll(metadataKey); + if (values != null) { + values.forEach(value -> destination.add(new HttpExchangeNameValue(key, value))); } - final Metadata.Key keyAscii = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); - final String value = source.get(keyAscii); - if (value != null) { - target.put(key, value); + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture ASCII gRPC metadata entry {}", key, throwable); + } + } + + private static void copyBinaryMetadataValues( + final Metadata source, + final String key, + final List destination) { + try { + final Metadata.Key metadataKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); + final Iterable values = source.getAll(metadataKey); + if (values != null) { + values.forEach( + value -> destination.add( + new HttpExchangeNameValue(key, Base64.getEncoder().encodeToString(value)) + ) + ); } + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture binary gRPC metadata entry {}", key, throwable); + } + } + + private static boolean containsName(final List values, final String name) { + return values.stream().anyMatch(value -> name.equalsIgnoreCase(value.name())); + } + + /** + * Builder for {@link AllureGrpc} capture configuration. + */ + public static final class Builder { + private AllureLifecycle lifecycle = Allure.getLifecycle(); + private boolean markStepFailedOnNonZeroCode = true; + private boolean captureRequestMetadata = true; + private boolean captureResponseMetadata = true; + private Consumer exchangeCustomizer = exchange -> { + }; + + private Builder() { + } + + /** + * Sets the Allure lifecycle used to report calls. + * + * @param lifecycle the lifecycle to use + * @return this builder + */ + public Builder lifecycle(final AllureLifecycle lifecycle) { + this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must not be null"); + return this; + } + + /** + * Configures whether a non-zero gRPC status fails the reported step. + * + * @param enabled whether a non-zero status fails the step + * @return this builder + */ + public Builder markStepFailedOnNonZeroCode(final boolean enabled) { + this.markStepFailedOnNonZeroCode = enabled; + return this; + } + + /** + * Configures request metadata capture. + * + * @param enabled whether to capture request metadata + * @return this builder + */ + public Builder captureRequestMetadata(final boolean enabled) { + this.captureRequestMetadata = enabled; + return this; + } + + /** + * Configures response header and trailer capture. + * + * @param enabled whether to capture response metadata + * @return this builder + */ + public Builder captureResponseMetadata(final boolean enabled) { + this.captureResponseMetadata = enabled; + return this; + } + + /** + * Adds a case-insensitive metadata name to the redaction policy. + * + * @param name the metadata name to redact + * @return this builder + */ + public Builder redactHeader(final String name) { + return configureExchange(exchange -> exchange.redactHeader(name)); + } + + /** + * Adds a case-insensitive cookie name to the redaction policy. + * + * @param name the cookie name to redact + * @return this builder + */ + public Builder redactCookie(final String name) { + return configureExchange(exchange -> exchange.redactCookie(name)); + } + + /** + * Adds an HTTP exchange capture customizer. + * + * @param customizer the customizer to apply when an exchange is built + * @return this builder + */ + public Builder configureExchange(final Consumer customizer) { + exchangeCustomizer = exchangeCustomizer.andThen( + Objects.requireNonNull(customizer, "customizer must not be null") + ); + return this; + } + + /** + * Builds the configured interceptor. + * + * @return the configured interceptor + */ + public AllureGrpc build() { + return new AllureGrpc(this); } } @@ -453,8 +662,9 @@ private static final class StepContext { private final AllureLifecycle lifecycle; private final List clientMessages; private final List serverMessages; - private final Map initialHeaders; - private final Map trailers; + private final AtomicReference> requestHeaders = new AtomicReference<>(List.of()); + private final List initialHeaders; + private final List trailers; private final String authority; private final long start; @@ -464,8 +674,8 @@ private static final class StepContext { final AllureLifecycle lifecycle, final List clientMessages, final List serverMessages, - final Map initialHeaders, - final Map trailers, + final List initialHeaders, + final List trailers, final String authority, final long start) { this.stepKey = stepKey; @@ -499,11 +709,19 @@ List getServerMessages() { return serverMessages; } - Map getInitialHeaders() { + List getRequestHeaders() { + return requestHeaders.get(); + } + + void setRequestHeaders(final List requestHeaders) { + this.requestHeaders.set(requestHeaders); + } + + List getInitialHeaders() { return initialHeaders; } - Map getTrailers() { + List getTrailers() { return trailers; } diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index d71e0f53..5687de5f 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -17,8 +17,19 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.grpc.CallCredentials; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientStreamTracer; +import io.grpc.ForwardingServerCall; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; @@ -33,15 +44,18 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static java.util.Arrays.asList; @@ -52,14 +66,28 @@ import static org.grpcmock.GrpcMock.serverStreamingMethod; import static org.grpcmock.GrpcMock.unaryMethod; -@ExtendWith(GrpcMockExtension.class) @IsolatedLifecycle class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; private static final String GRPC_EXCHANGE = "gRPC exchange"; + private static final String REDACTED_VALUE = "__ALLURE_REDACTED__"; + private static final Metadata.Key REQUEST_ID = asciiKey("x-request-id"); + private static final Metadata.Key REPEATED_REQUEST = asciiKey("x-request-repeated"); + private static final Metadata.Key BINARY_REQUEST = binaryKey("x-request-bin"); + private static final Metadata.Key AUTHORIZATION = asciiKey("authorization"); + private static final Metadata.Key API_KEY = asciiKey("x-api-key"); + private static final Metadata.Key COOKIE = asciiKey("cookie"); + private static final Metadata.Key RESPONSE_HEADER = asciiKey("x-response-repeated"); + private static final Metadata.Key RESPONSE_TRAILER = asciiKey("x-response-trailer"); + private static final Metadata.Key SET_COOKIE = asciiKey("set-cookie"); private static final ObjectMapper JSON = new ObjectMapper(); + @RegisterExtension + static final GrpcMockExtension GRPC_MOCK = GrpcMockExtension.builder() + .withInterceptor(new ResponseMetadataInterceptor()) + .build(); + private ManagedChannel managedChannel; @BeforeEach @@ -320,6 +348,145 @@ void unaryRequestBodyIsCapturedAsJsonObject() throws Exception { assertThat(exchange.at("/request/body/contentType").asText()).isEqualTo("application/grpc+json"); } + /** + * Request metadata added by call credentials and response metadata are captured by default, including repeated + * and binary values. + */ + @Test + void metadataIsCapturedByDefaultAfterCredentialsAreApplied() throws Exception { + Metadata credentialMetadata = new Metadata(); + credentialMetadata.put(REQUEST_ID, "request-42"); + credentialMetadata.put(REPEATED_REQUEST, "first"); + credentialMetadata.put(REPEATED_REQUEST, "second"); + credentialMetadata.put(BINARY_REQUEST, "binary-value".getBytes(StandardCharsets.UTF_8)); + credentialMetadata.put(AUTHORIZATION, "Bearer secret"); + credentialMetadata.put(COOKIE, "session=request-secret; theme=dark"); + + AllureResults allureResults = executeUnaryWithConfiguration( + Request.newBuilder().setTopic("metadata-defaults").build(), + AllureGrpc::new, + callCredentials(credentialMetadata) + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValuesByName(exchange.at("/request/headers"), REQUEST_ID.name())) + .containsExactly("request-42"); + assertThat(findValuesByName(exchange.at("/request/headers"), REPEATED_REQUEST.name())) + .containsExactly("first", "second"); + assertThat(findValuesByName(exchange.at("/request/headers"), BINARY_REQUEST.name())) + .containsExactly("YmluYXJ5LXZhbHVl"); + assertThat(findValuesByName(exchange.at("/request/headers"), AUTHORIZATION.name())) + .containsExactly(REDACTED_VALUE); + assertThat(findValuesByName(exchange.at("/request/headers"), COOKIE.name())).isEmpty(); + assertThat(findValueByName(exchange.at("/request/cookies"), "session")) + .contains("request-secret"); + assertThat(findValueByName(exchange.at("/request/cookies"), "theme")) + .contains("dark"); + assertThat(findValuesByName(exchange.at("/response/headers"), RESPONSE_HEADER.name())) + .containsExactly("first", "second"); + assertThat(findValuesByName(exchange.at("/response/headers"), SET_COOKIE.name())).isEmpty(); + assertThat(findValueByName(exchange.at("/response/cookies"), "session")) + .contains("response-secret"); + assertThat(findValueByName(exchange.at("/response/cookies"), "theme")) + .contains("light"); + assertThat(findValuesByName(exchange.at("/response/trailers"), RESPONSE_TRAILER.name())) + .containsExactly("trailer-value"); + + JsonNode responseSession = findByName(exchange.at("/response/cookies"), "session").orElseThrow(); + assertThat(responseSession.path("path").asText()).isEqualTo("/"); + assertThat(responseSession.path("domain").asText()).isEqualTo("example.test"); + assertThat(responseSession.path("expires").asText()).isEqualTo("Wed, 21 Oct 2015 07:28:00 GMT"); + assertThat(responseSession.path("httpOnly").asBoolean()).isTrue(); + assertThat(responseSession.path("secure").asBoolean()).isTrue(); + assertThat(responseSession.path("sameSite").asText()).isEqualTo("Lax"); + } + + /** + * Metadata applied after tracer creation is refreshed when a transport-level failing stream closes without ever + * invoking {@link ClientStreamTracer#streamCreated(io.grpc.Attributes, Metadata)}. + */ + @Test + void finalRequestMetadataIsCapturedWhenTransportStreamCreationFails() throws Exception { + AllureResults allureResults = Allure.step( + "Execute a gRPC request whose transport stream cannot be created", + () -> runWithinTestContext(() -> { + ClientCall call = new AllureGrpc().interceptCall( + TestServiceGrpc.getCalculateMethod(), + CallOptions.DEFAULT, + failingTransportChannel() + ); + call.start(new ClientCall.Listener<>() { + }, new Metadata()); + }) + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValuesByName(exchange.at("/request/headers"), REQUEST_ID.name())) + .containsExactly("failed-request-42"); + } + + /** + * Callers can turn request and response metadata capture off independently of message and status capture. + */ + @Test + void metadataCaptureCanBeDisabled() throws Exception { + Metadata credentialMetadata = new Metadata(); + credentialMetadata.put(REQUEST_ID, "request-42"); + + AllureResults allureResults = executeUnaryWithConfiguration( + Request.newBuilder().setTopic("metadata-disabled").build(), + () -> AllureGrpc.builder() + .captureRequestMetadata(false) + .captureResponseMetadata(false) + .build(), + callCredentials(credentialMetadata) + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValuesByName(exchange.at("/request/headers"), REQUEST_ID.name())).isEmpty(); + assertThat(findValuesByName(exchange.at("/response/headers"), RESPONSE_HEADER.name())).isEmpty(); + assertThat(findValuesByName(exchange.at("/response/trailers"), RESPONSE_TRAILER.name())).isEmpty(); + assertThat(findValueByName(exchange.at("/response/trailers"), "grpc-status")).contains("0"); + } + + /** + * Header and structured-cookie redaction configured on the gRPC builder is applied by the HTTP exchange builder. + */ + @Test + void configuredHeadersAndCookiesAreRedacted() throws Exception { + Metadata credentialMetadata = new Metadata(); + credentialMetadata.put(API_KEY, "api-secret"); + credentialMetadata.put(COOKIE, "theme=dark; session=request-secret"); + + AllureResults allureResults = executeUnaryWithConfiguration( + Request.newBuilder().setTopic("metadata-redaction").build(), + () -> AllureGrpc.builder() + .configureExchange(HttpExchange.Builder::clearRedactedHeaders) + .redactHeader(API_KEY.name()) + .redactCookie("session") + .build(), + callCredentials(credentialMetadata) + ); + + JsonNode exchange = readGrpcExchangeAttachment(allureResults); + + assertThat(findValuesByName(exchange.at("/request/headers"), API_KEY.name())) + .containsExactly(REDACTED_VALUE); + assertThat(findValuesByName(exchange.at("/request/headers"), COOKIE.name())).isEmpty(); + assertThat(findValueByName(exchange.at("/request/cookies"), "session")) + .contains(REDACTED_VALUE); + assertThat(findValueByName(exchange.at("/request/cookies"), "theme")) + .contains("dark"); + assertThat(findValuesByName(exchange.at("/response/headers"), SET_COOKIE.name())).isEmpty(); + assertThat(findValueByName(exchange.at("/response/cookies"), "session")) + .contains(REDACTED_VALUE); + assertThat(findValueByName(exchange.at("/response/cookies"), "theme")) + .contains("light"); + } + @Test void unaryResponseBodyIsCapturedAsJsonObject() throws Exception { GrpcMock.stubFor( @@ -417,14 +584,116 @@ private static JsonNode readGrpcExchangeAttachment(AllureResults allureResults) } private static Optional findValueByName(final JsonNode values, final String name) { + return findByName(values, name).map(value -> value.path("value").asText()); + } + + private static Optional findByName(final JsonNode values, final String name) { for (JsonNode value : values) { if (name.equals(value.path("name").asText())) { - return Optional.of(value.path("value").asText()); + return Optional.of(value); } } return Optional.empty(); } + private static List findValuesByName(final JsonNode values, final String name) { + List result = new ArrayList<>(); + for (JsonNode value : values) { + if (name.equals(value.path("name").asText())) { + result.add(value.path("value").asText()); + } + } + return result; + } + + private static Metadata.Key asciiKey(final String name) { + return Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER); + } + + private static Metadata.Key binaryKey(final String name) { + return Metadata.Key.of(name, Metadata.BINARY_BYTE_MARSHALLER); + } + + private static CallCredentials callCredentials(final Metadata source) { + return new CallCredentials() { + @Override + public void applyRequestMetadata( + final RequestInfo requestInfo, + final Executor appExecutor, + final MetadataApplier applier) { + appExecutor.execute(() -> { + Metadata metadata = new Metadata(); + metadata.merge(source); + applier.apply(metadata); + }); + } + }; + } + + private static Channel failingTransportChannel() { + return new Channel() { + @Override + public String authority() { + return "unavailable.example"; + } + + @Override + public ClientCall newCall( + final MethodDescriptor methodDescriptor, + final CallOptions callOptions) { + return new ClientCall<>() { + @Override + public void start(final Listener responseListener, final Metadata headers) { + final List factories = callOptions.getStreamTracerFactories(); + final ClientStreamTracer.Factory factory = factories.get(factories.size() - 1); + final ClientStreamTracer tracer = factory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder() + .setCallOptions(callOptions) + .build(), + headers + ); + + headers.put(REQUEST_ID, "failed-request-42"); + tracer.streamClosed(Status.UNAVAILABLE); + responseListener.onClose(Status.UNAVAILABLE, new Metadata()); + } + + @Override + public void request(final int numMessages) { + } + + @Override + public void cancel(final String message, final Throwable cause) { + } + + @Override + public void halfClose() { + } + + @Override + public void sendMessage(final ReqT message) { + } + }; + } + }; + } + + private AllureResults executeUnaryWithConfiguration( + final Request request, + final Supplier interceptor, + final CallCredentials credentials) { + return Allure.step( + "Execute configured unary gRPC request and collect Allure results", + () -> runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = TestServiceGrpc.newBlockingStub(managedChannel) + .withInterceptors(interceptor.get()) + .withCallCredentials(credentials); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); + }) + ); + } + protected final AllureResults executeUnary(Request request) { return Allure.step( "Execute unary gRPC request and collect Allure results", @@ -475,4 +744,35 @@ protected final AllureResults executeUnaryExpectingException(Request request) { ); } + private static final class ResponseMetadataInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall( + final ServerCall call, + final Metadata headers, + final ServerCallHandler next) { + final ServerCall forwardingCall = new ForwardingServerCall.SimpleForwardingServerCall(call) { + @Override + public void sendHeaders(final Metadata responseHeaders) { + responseHeaders.put(RESPONSE_HEADER, "first"); + responseHeaders.put(RESPONSE_HEADER, "second"); + responseHeaders.put( + SET_COOKIE, + "session=response-secret; Path=/; Domain=example.test; " + + "Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly; Secure; SameSite=Lax" + ); + responseHeaders.put(SET_COOKIE, "theme=light; Path=/"); + super.sendHeaders(responseHeaders); + } + + @Override + public void close(final Status status, final Metadata trailers) { + trailers.put(RESPONSE_TRAILER, "trailer-value"); + super.close(status, trailers); + } + }; + return next.startCall(forwardingCall, headers); + } + } + } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeCookieParser.java b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeCookieParser.java new file mode 100644 index 00000000..b2126bde --- /dev/null +++ b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeCookieParser.java @@ -0,0 +1,197 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed 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 io.qameta.allure.http; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +final class HttpExchangeCookieParser { + + private static final String COOKIE_HEADER = "cookie"; + private static final String SET_COOKIE_HEADER = "set-cookie"; + private static final String COOKIE_SEPARATOR = ";"; + private static final String TOKEN_SEPARATORS = "()<>@,;:\\\"/[]?={}"; + + private HttpExchangeCookieParser() { + throw new IllegalStateException("Utility class"); + } + + static boolean isCookieHeader(final String name) { + return COOKIE_HEADER.equalsIgnoreCase(name); + } + + static boolean isSetCookieHeader(final String name) { + return SET_COOKIE_HEADER.equalsIgnoreCase(name); + } + + static Optional> parseCookieHeader(final String value) { + final List result = new ArrayList<>(); + for (String part : value.split(COOKIE_SEPARATOR, -1)) { + final Optional cookie = parseCookiePair(part); + if (cookie.isEmpty()) { + return Optional.empty(); + } + result.add(cookie.orElseThrow()); + } + return result.isEmpty() ? Optional.empty() : Optional.of(List.copyOf(result)); + } + + static Optional parseSetCookieHeader(final String value) { + final String[] parts = value.split(COOKIE_SEPARATOR, -1); + final Optional cookie = parseCookiePair(parts[0]); + if (cookie.isEmpty()) { + return Optional.empty(); + } + + final CookieAttributes attributes = new CookieAttributes(); + for (int index = 1; index < parts.length; index++) { + if (!attributes.add(parts[index])) { + return Optional.empty(); + } + } + + return Optional.of(attributes.toCookie(cookie.orElseThrow())); + } + + private static Optional parseCookiePair(final String value) { + final int separator = value.indexOf('='); + if (separator <= 0) { + return Optional.empty(); + } + final String name = value.substring(0, separator).trim(); + final String cookieValue = value.substring(separator + 1).trim(); + if (!isToken(name) || !isCookieValue(cookieValue)) { + return Optional.empty(); + } + return Optional.of(new HttpExchangeCookie(name, cookieValue)); + } + + private static boolean isToken(final String value) { + if (value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + final char character = value.charAt(index); + if (character <= ' ' || character >= '\u007f' || TOKEN_SEPARATORS.indexOf(character) >= 0) { + return false; + } + } + return true; + } + + private static boolean isCookieValue(final String value) { + final boolean quoted = value.length() >= 2 && value.charAt(0) == '"' + && value.charAt(value.length() - 1) == '"'; + final int start = quoted ? 1 : 0; + final int end = quoted ? value.length() - 1 : value.length(); + for (int index = start; index < end; index++) { + if (!isCookieOctet(value.charAt(index))) { + return false; + } + } + return quoted || value.indexOf('"') < 0; + } + + private static boolean isCookieOctet(final char character) { + return character == '!' + || character >= '#' && character <= '+' + || character >= '-' && character <= ':' + || character >= '<' && character <= '[' + || character >= ']' && character <= '~'; + } + + private static final class CookieAttributes { + private final Set seen = new HashSet<>(); + private String path; + private String domain; + private String expires; + private Boolean httpOnly; + private Boolean secure; + private String sameSite; + + boolean add(final String rawAttribute) { + final String attribute = rawAttribute.trim(); + final int separator = attribute.indexOf('='); + final String name = (separator < 0 ? attribute : attribute.substring(0, separator)).trim(); + if (!isToken(name)) { + return false; + } + + final String normalizedName = name.toLowerCase(Locale.ROOT); + if (!seen.add(normalizedName)) { + return false; + } + + final String value = separator < 0 ? null : attribute.substring(separator + 1).trim(); + return switch (normalizedName) { + case "path" -> setPath(value); + case "domain" -> setDomain(value); + case "expires" -> setExpires(value); + case "httponly" -> setHttpOnly(value); + case "secure" -> setSecure(value); + case "samesite" -> setSameSite(value); + default -> false; + }; + } + + HttpExchangeCookie toCookie(final HttpExchangeCookie cookie) { + return new HttpExchangeCookie( + cookie.name(), + cookie.value(), + path, + domain, + expires, + httpOnly, + secure, + sameSite + ); + } + + private boolean setPath(final String value) { + path = value; + return value != null; + } + + private boolean setDomain(final String value) { + domain = value; + return value != null; + } + + private boolean setExpires(final String value) { + expires = value; + return value != null; + } + + private boolean setHttpOnly(final String value) { + httpOnly = value == null ? true : null; + return value == null; + } + + private boolean setSecure(final String value) { + secure = value == null ? true : null; + return value == null; + } + + private boolean setSameSite(final String value) { + sameSite = value; + return value != null; + } + } +} diff --git a/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeRequest.java b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeRequest.java index 35c60db6..538b2f6a 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeRequest.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeRequest.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * HTTP request captured in an exchange attachment. @@ -76,13 +77,34 @@ public Builder setHttpVersion(final String httpVersion) { return this; } + /** + * Adds a request header, converting a valid Cookie header to structured cookies. + * + * @param name the header name + * @param value the header value + * @return this builder + */ public Builder addHeader(final String name, final String value) { - headers.add(new HttpExchangeNameValue(name, value)); + final HttpExchangeNameValue header = new HttpExchangeNameValue(name, value); + if (HttpExchangeCookieParser.isCookieHeader(name)) { + final Optional> parsed = HttpExchangeCookieParser.parseCookieHeader(value); + if (parsed.isPresent()) { + cookies.addAll(parsed.orElseThrow()); + return this; + } + } + headers.add(header); return this; } + /** + * Adds request headers, converting every valid Cookie header to structured cookies. + * + * @param headers the headers to add + * @return this builder + */ public Builder addHeaders(final List headers) { - this.headers.addAll(headers); + headers.forEach(header -> addHeader(header.name(), header.value())); return this; } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeResponse.java b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeResponse.java index 41b87649..37454e81 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeResponse.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/http/HttpExchangeResponse.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** * HTTP response captured in an exchange attachment. @@ -78,13 +79,26 @@ public Builder setHttpVersion(final String httpVersion) { return this; } + /** + * Adds a response header, converting a valid Set-Cookie header to a structured cookie. + * + * @param name the header name + * @param value the header value + * @return this builder + */ public Builder addHeader(final String name, final String value) { - headers.add(new HttpExchangeNameValue(name, value)); + addHeaderOrCookie(headers, name, value); return this; } + /** + * Adds response headers, converting every valid Set-Cookie header to a structured cookie. + * + * @param headers the headers to add + * @return this builder + */ public Builder addHeaders(final List headers) { - this.headers.addAll(headers); + headers.forEach(header -> addHeader(header.name(), header.value())); return this; } @@ -93,13 +107,25 @@ public Builder addCookie(final String name, final String value) { return this; } + public Builder addCookies(final List cookies) { + this.cookies.addAll(cookies); + return this; + } + public Builder setBody(final HttpExchangeBody body) { this.body = body; return this; } + /** + * Adds a response trailer, converting a valid Set-Cookie field to a structured cookie. + * + * @param name the trailer name + * @param value the trailer value + * @return this builder + */ public Builder addTrailer(final String name, final String value) { - trailers.add(new HttpExchangeNameValue(name, value)); + addHeaderOrCookie(trailers, name, value); return this; } @@ -118,5 +144,20 @@ body, nullIfEmpty(trailers), nullIfEmpty(informationalResponses) private static List nullIfEmpty(final List values) { return values.isEmpty() ? null : values; } + + private void addHeaderOrCookie( + final List destination, + final String name, + final String value) { + final HttpExchangeNameValue header = new HttpExchangeNameValue(name, value); + if (HttpExchangeCookieParser.isSetCookieHeader(name)) { + final Optional parsed = HttpExchangeCookieParser.parseSetCookieHeader(value); + if (parsed.isPresent()) { + cookies.add(parsed.orElseThrow()); + return; + } + } + destination.add(header); + } } } diff --git a/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeCookieBuilderTest.java b/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeCookieBuilderTest.java new file mode 100644 index 00000000..39432b9d --- /dev/null +++ b/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeCookieBuilderTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed 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 io.qameta.allure.http; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.qameta.allure.Allure.step; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class HttpExchangeCookieBuilderTest { + + @Test + void requestBuilderConvertsCookieHeadersToStructuredCookies() { + final HttpExchangeRequest request = step( + "Build a request from individual and bulk headers", + () -> HttpExchangeRequest.builder("GET", "https://example.test") + .addHeader("Cookie", "session=abc=123; theme=dark") + .addHeaders( + List.of( + new HttpExchangeNameValue("X-Trace", "trace-1"), + new HttpExchangeNameValue("cOoKiE", "locale=en-GB") + ) + ) + .build() + ); + + step("Verify Cookie headers are represented once as structured cookies", () -> { + assertThat(request.headers()) + .extracting(HttpExchangeNameValue::name, HttpExchangeNameValue::value) + .containsExactly(tuple("X-Trace", "trace-1")); + assertThat(request.cookies()) + .extracting(HttpExchangeCookie::name, HttpExchangeCookie::value) + .containsExactly( + tuple("session", "abc=123"), + tuple("theme", "dark"), + tuple("locale", "en-GB") + ); + }); + } + + @Test + void responseBuilderConvertsSetCookieHeadersAndTrailersToStructuredCookies() { + final HttpExchangeResponse response = step( + "Build a response from headers and trailers", + () -> HttpExchangeResponse.builder() + .addHeader( + "SET-COOKIE", + "session=response-secret; Path=/; Domain=example.test; " + + "Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly; Secure; SameSite=Lax" + ) + .addHeaders( + List.of( + new HttpExchangeNameValue("X-Trace", "trace-1"), + new HttpExchangeNameValue("set-cookie", "theme=light") + ) + ) + .addTrailer("Set-Cookie", "trailer=value") + .build() + ); + + step("Verify Set-Cookie fields preserve supported attributes without raw duplicates", () -> { + assertThat(response.headers()) + .extracting(HttpExchangeNameValue::name, HttpExchangeNameValue::value) + .containsExactly(tuple("X-Trace", "trace-1")); + assertThat(response.trailers()).isNull(); + assertThat(response.cookies()) + .extracting( + HttpExchangeCookie::name, + HttpExchangeCookie::value, + HttpExchangeCookie::path, + HttpExchangeCookie::domain, + HttpExchangeCookie::expires, + HttpExchangeCookie::httpOnly, + HttpExchangeCookie::secure, + HttpExchangeCookie::sameSite + ) + .containsExactly( + tuple( + "session", + "response-secret", + "/", + "example.test", + "Wed, 21 Oct 2015 07:28:00 GMT", + true, + true, + "Lax" + ), + tuple("theme", "light", null, null, null, null, null, null), + tuple("trailer", "value", null, null, null, null, null, null) + ); + }); + } + + @Test + void buildersKeepRawCookieHeadersWhenConversionWouldLoseInformation() { + final HttpExchangeRequest request = step( + "Build a request with a malformed Cookie header", + () -> HttpExchangeRequest.builder("GET", "https://example.test") + .addHeader("Cookie", "session=value; malformed") + .build() + ); + final HttpExchangeResponse response = step( + "Build a response with unsupported and malformed Set-Cookie fields", + () -> HttpExchangeResponse.builder() + .addHeader("Set-Cookie", "session=value; Max-Age=60") + .addTrailer("Set-Cookie", "malformed") + .build() + ); + + step("Verify the original fields remain available for lossless capture", () -> { + assertThat(request.cookies()).isNull(); + assertThat(request.headers()) + .extracting(HttpExchangeNameValue::name, HttpExchangeNameValue::value) + .containsExactly(tuple("Cookie", "session=value; malformed")); + assertThat(response.cookies()).isNull(); + assertThat(response.headers()) + .extracting(HttpExchangeNameValue::name, HttpExchangeNameValue::value) + .containsExactly(tuple("Set-Cookie", "session=value; Max-Age=60")); + assertThat(response.trailers()) + .extracting(HttpExchangeNameValue::name, HttpExchangeNameValue::value) + .containsExactly(tuple("Set-Cookie", "malformed")); + }); + } +} diff --git a/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeProcessorTest.java b/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeProcessorTest.java index 1d0e78e7..e88ddbcd 100644 --- a/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeProcessorTest.java +++ b/allure-java-commons/src/test/java/io/qameta/allure/http/HttpExchangeProcessorTest.java @@ -67,8 +67,7 @@ void shouldApplyBuilderOptionsWhenExchangeIsBuilt() { "POST", "https://example.test/api", request -> request .addHeader("Authorization", "Bearer token") .addHeader("Accept", "application/json") - .addCookie("SESSION", "cookie-secret") - .addCookie("theme", "dark") + .addHeader("Cookie", "SESSION=cookie-secret; theme=dark") .addQuery("token", "query-secret") .addQuery("page", "1") .setBody(body) diff --git a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java index 1f7c9752..737fe68c 100644 --- a/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java +++ b/allure-rest-assured/src/test/java/io/qameta/allure/restassured/AllureRestAssuredTest.java @@ -355,7 +355,10 @@ void shouldApplyHttpExchangeBuilderOptions() { "Verify REST Assured exchange uses shared redaction and truncation", () -> assertThat( results.getAttachmentContentAsString(httpExchangeAttachment(results)) ) - .contains("\"name\":\"sid\",\"value\":\"" + HttpExchange.REDACTED_VALUE + "\"") + .containsOnlyOnce( + "\"name\":\"sid\",\"value\":\"" + HttpExchange.REDACTED_VALUE + "\"" + ) + .doesNotContain("\"name\":\"Cookie\"") .contains("\"name\":\"token\",\"value\":\"" + HttpExchange.REDACTED_VALUE + "\"") .contains("\"name\":\"secret\",\"value\":\"" + HttpExchange.REDACTED_VALUE + "\"") .contains("\"value\":\"resp\"") diff --git a/allure-servlet-api/src/main/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilder.java b/allure-servlet-api/src/main/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilder.java index aae57468..d819af35 100644 --- a/allure-servlet-api/src/main/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilder.java +++ b/allure-servlet-api/src/main/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilder.java @@ -21,13 +21,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; /** @@ -58,12 +56,6 @@ public static HttpExchangeRequest buildRequest(final HttpServletRequest request) final String value = request.getHeader(name); requestBuilder.addHeader(name, value); }); - - final Cookie[] cookies = request.getCookies(); - if (cookies != null) { - Arrays.stream(cookies) - .forEach(cookie -> requestBuilder.addCookie(cookie.getName(), cookie.getValue())); - } requestBuilder.setBody(HttpExchangeBody.utf8(getBody(request))); return requestBuilder.build(); } diff --git a/allure-servlet-api/src/test/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilderTest.java b/allure-servlet-api/src/test/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilderTest.java index 744287e2..2f66dd3c 100644 --- a/allure-servlet-api/src/test/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilderTest.java +++ b/allure-servlet-api/src/test/java/io/qameta/allure/servletapi/HttpServletAttachmentBuilderTest.java @@ -20,7 +20,6 @@ import io.qameta.allure.http.HttpExchangeResponse; import org.junit.jupiter.api.Test; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -41,10 +40,10 @@ void shouldBuildRequestWithHeadersCookiesAndBody() throws Exception { final HttpServletRequest request = mock(HttpServletRequest.class); when(request.getMethod()).thenReturn("POST"); when(request.getRequestURI()).thenReturn("/orders"); - when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of("X-Trace", "Accept"))); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of("X-Trace", "Cookie", "Accept"))); when(request.getHeader("X-Trace")).thenReturn("trace-1"); + when(request.getHeader("Cookie")).thenReturn("session=abc123"); when(request.getHeader("Accept")).thenReturn("application/json"); - when(request.getCookies()).thenReturn(new Cookie[]{new Cookie("session", "abc123")}); when(request.getReader()).thenReturn(new BufferedReader(new StringReader("{\"ok\":true}"))); final HttpExchangeRequest attachment = Allure.step( @@ -67,12 +66,11 @@ void shouldBuildRequestWithHeadersCookiesAndBody() throws Exception { } @Test - void shouldHandleRequestsWithoutCookies() throws Exception { + void shouldHandleRequestsWithoutCookieHeaders() throws Exception { final HttpServletRequest request = mock(HttpServletRequest.class); when(request.getMethod()).thenReturn("GET"); when(request.getRequestURI()).thenReturn("/orders"); when(request.getHeaderNames()).thenReturn(Collections.emptyEnumeration()); - when(request.getCookies()).thenReturn(null); when(request.getReader()).thenReturn(new BufferedReader(new StringReader(""))); final HttpExchangeRequest attachment = Allure.step(