From 7fc493719962d2ea910866ade9b273b7bac87756 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 7 Jun 2024 10:03:52 +0200 Subject: [PATCH] Add Partitioned cookie attribute support for servers This commit adds support for the "Partitioned" cookie attribute in WebFlux servers and the related testing infrastructure. Note, Undertow does not support this feature at the moment. Closes gh-31454 --- .../springframework/mock/web/MockCookie.java | 27 ++++++++++++++ .../mock/web/MockHttpServletResponse.java | 3 ++ .../web/reactive/server/CookieAssertions.java | 13 +++++++ .../servlet/client/MockMvcHttpConnector.java | 1 + .../servlet/result/CookieResultMatchers.java | 13 ++++++- .../servlet/result/CookieResultMatchersDsl.kt | 8 +++++ .../mock/web/MockCookieTests.java | 11 +++++- .../web/MockHttpServletResponseTests.java | 3 +- ...nTests.java => CookieAssertionsTests.java} | 9 ++++- .../springframework/http/ResponseCookie.java | 35 +++++++++++++++++-- .../ReactorNetty2ServerHttpResponse.java | 1 + .../reactive/ReactorServerHttpResponse.java | 1 + .../reactive/ServletServerHttpResponse.java | 11 ++++++ .../reactive/UndertowServerHttpResponse.java | 1 + .../http/ResponseCookieTests.java | 4 +-- .../reactive/CookieIntegrationTests.java | 23 ++++++++++-- .../web/testfixture/servlet/MockCookie.java | 21 +++++++++++ .../servlet/MockHttpServletResponse.java | 3 ++ 18 files changed, 178 insertions(+), 10 deletions(-) rename spring-test/src/test/java/org/springframework/test/web/reactive/server/{CookieAssertionTests.java => CookieAssertionsTests.java} (94%) diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java b/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java index 59f1c395e0..be119c2907 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java @@ -98,6 +98,24 @@ public class MockCookie extends Cookie { return getAttribute(SAME_SITE); } + /** + * Set the "Partitioned" attribute for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public void setPartitioned(boolean partitioned) { + setAttribute("Partitioned", ""); + } + + /** + * Return whether the "Partitioned" attribute is set for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return getAttribute("Partitioned") != null; + } + /** * Factory method that parses the value of the supplied "Set-Cookie" header. * @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty @@ -146,6 +164,9 @@ public class MockCookie extends Cookie { else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) { cookie.setComment(extractAttributeValue(attribute, setCookieHeader)); } + else if (!attribute.isEmpty()) { + cookie.setAttribute(attribute, extractOptionalAttributeValue(attribute, setCookieHeader)); + } } return cookie; } @@ -157,6 +178,11 @@ public class MockCookie extends Cookie { return nameAndValue[1]; } + private static String extractOptionalAttributeValue(String attribute, String header) { + String[] nameAndValue = attribute.split("="); + return nameAndValue.length == 2 ? nameAndValue[1] : ""; + } + @Override public void setAttribute(String name, @Nullable String value) { if (EXPIRES.equalsIgnoreCase(name)) { @@ -176,6 +202,7 @@ public class MockCookie extends Cookie { .append("Comment", getComment()) .append("Secure", getSecure()) .append("HttpOnly", isHttpOnly()) + .append("Partitioned", isPartitioned()) .append(SAME_SITE, getSameSite()) .append("Max-Age", getMaxAge()) .append(EXPIRES, getAttribute(EXPIRES)) diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java index 08a6200fba..1f9f77b2f1 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java @@ -481,6 +481,9 @@ public class MockHttpServletResponse implements HttpServletResponse { if (cookie.isHttpOnly()) { buf.append("; HttpOnly"); } + if (cookie.getAttribute("Partitioned") != null) { + buf.append("; Partitioned"); + } if (cookie instanceof MockCookie mockCookie) { if (StringUtils.hasText(mockCookie.getSameSite())) { buf.append("; SameSite=").append(mockCookie.getSameSite()); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java index e29e4e5295..df5bdbd46e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -197,6 +197,19 @@ public class CookieAssertions { return this.responseSpec; } + /** + * Assert a cookie's "Partitioned" attribute. + * @since 6.2 + */ + public WebTestClient.ResponseSpec partitioned(String name, boolean expected) { + boolean isPartitioned = getCookie(name).isPartitioned(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " isPartitioned"; + assertEquals(message, expected, isPartitioned); + }); + return this.responseSpec; + } + /** * Assert a cookie's "SameSite" attribute. */ diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java index d54278330a..e387a3b65e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java @@ -209,6 +209,7 @@ public class MockMvcHttpConnector implements ClientHttpConnector { .path(cookie.getPath()) .secure(cookie.getSecure()) .httpOnly(cookie.isHttpOnly()) + .partitioned(cookie.getAttribute("Partitioned") != null) .sameSite(cookie.getAttribute("samesite")) .build(); clientResponse.getCookies().add(httpCookie.getName(), httpCookie); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java index 88ed2f04ed..01a346d36e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/CookieResultMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -229,6 +229,17 @@ public class CookieResultMatchers { }; } + /** + * Assert whether the cookie is partitioned. + * @since 6.2 + */ + public ResultMatcher partitioned(String name, boolean partitioned) { + return result -> { + Cookie cookie = getCookie(result, name); + assertEquals("Response cookie '" + name + "' partitioned", partitioned, cookie.getAttribute("Partitioned") != null); + }; + } + /** * Assert a cookie's specified attribute with a Hamcrest {@link Matcher}. * @param cookieAttribute the name of the Cookie attribute (case-insensitive) diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt index 1c18645e53..6a9ee87335 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/CookieResultMatchersDsl.kt @@ -157,6 +157,14 @@ class CookieResultMatchersDsl internal constructor (private val actions: ResultA actions.andExpect(matchers.httpOnly(name, httpOnly)) } + /** + * @see CookieResultMatchers.partitioned + * @since 6.2 + */ + fun partitioned(name: String, partitioned: Boolean) { + actions.andExpect(matchers.partitioned(name, partitioned)) + } + /** * @see CookieResultMatchers.attribute * @since 6.0.8 diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java index 6a55669caf..4f2e7a019b 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockCookieTests.java @@ -71,7 +71,7 @@ class MockCookieTests { @Test void parseHeaderWithAttributes() { MockCookie cookie = MockCookie.parse("SESSION=123; Domain=example.com; Max-Age=60; " + - "Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; SameSite=Lax"); + "Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; Partitioned; SameSite=Lax"); assertCookie(cookie, "SESSION", "123"); assertThat(cookie.getDomain()).isEqualTo("example.com"); @@ -79,6 +79,7 @@ class MockCookieTests { assertThat(cookie.getPath()).isEqualTo("/"); assertThat(cookie.getSecure()).isTrue(); assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.isPartitioned()).isTrue(); assertThat(cookie.getExpires()).isEqualTo(ZonedDateTime.parse("Tue, 8 Oct 2019 19:50:00 GMT", DateTimeFormatter.RFC_1123_DATE_TIME)); assertThat(cookie.getSameSite()).isEqualTo("Lax"); @@ -203,4 +204,12 @@ class MockCookieTests { assertThatThrownBy(() -> cookie.setAttribute("expires", "12345")).isInstanceOf(DateTimeParseException.class); } + @Test + void setPartitioned() { + MockCookie cookie = new MockCookie("SESSION", "123"); + cookie.setAttribute("Partitioned", ""); + + assertThat(cookie.isPartitioned()).isTrue(); + } + } diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java index a6f2964ddc..0bc9975a7c 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java @@ -274,12 +274,13 @@ class MockHttpServletResponseTests { cookie.setMaxAge(0); cookie.setSecure(true); cookie.setHttpOnly(true); + cookie.setAttribute("Partitioned", ""); response.addCookie(cookie); assertThat(response.getHeader(SET_COOKIE)).isEqualTo(("foo=bar; Path=/path; Domain=example.com; " + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + - "Secure; HttpOnly")); + "Secure; HttpOnly; Partitioned")); } @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java similarity index 94% rename from spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java rename to spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java index c3eedbe645..a8c6ca455b 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java @@ -37,7 +37,7 @@ import static org.mockito.Mockito.mock; * * @author Rossen Stoyanchev */ -public class CookieAssertionTests { +public class CookieAssertionsTests { private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") .maxAge(Duration.ofMinutes(30)) @@ -45,6 +45,7 @@ public class CookieAssertionTests { .path("/foo") .secure(true) .httpOnly(true) + .partitioned(true) .sameSite("Lax") .build(); @@ -117,6 +118,12 @@ public class CookieAssertionTests { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false)); } + @Test + void partitioned() { + assertions.partitioned("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false)); + } + @Test void sameSite() { assertions.sameSite("foo", "Lax"); diff --git a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java index 485550614f..b65df60254 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java @@ -47,6 +47,8 @@ public final class ResponseCookie extends HttpCookie { private final boolean httpOnly; + private final boolean partitioned; + @Nullable private final String sameSite; @@ -55,7 +57,7 @@ public final class ResponseCookie extends HttpCookie { * Private constructor. See {@link #from(String, String)}. */ private ResponseCookie(String name, @Nullable String value, Duration maxAge, @Nullable String domain, - @Nullable String path, boolean secure, boolean httpOnly, @Nullable String sameSite) { + @Nullable String path, boolean secure, boolean httpOnly, boolean partitioned, @Nullable String sameSite) { super(name, value); Assert.notNull(maxAge, "Max age must not be null"); @@ -65,6 +67,7 @@ public final class ResponseCookie extends HttpCookie { this.path = path; this.secure = secure; this.httpOnly = httpOnly; + this.partitioned = partitioned; this.sameSite = sameSite; Rfc6265Utils.validateCookieName(name); @@ -116,6 +119,15 @@ public final class ResponseCookie extends HttpCookie { return this.httpOnly; } + /** + * Return {@code true} if the cookie has the "Partitioned" attribute. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return this.partitioned; + } + /** * Return the cookie "SameSite" attribute, or {@code null} if not set. *

This limits the scope of the cookie such that it will only be attached to @@ -139,6 +151,7 @@ public final class ResponseCookie extends HttpCookie { .path(this.path) .secure(this.secure) .httpOnly(this.httpOnly) + .partitioned(this.partitioned) .sameSite(this.sameSite); } @@ -180,6 +193,9 @@ public final class ResponseCookie extends HttpCookie { if (this.httpOnly) { sb.append("; HttpOnly"); } + if (this.partitioned) { + sb.append("; Partitioned"); + } if (StringUtils.hasText(this.sameSite)) { sb.append("; SameSite=").append(this.sameSite); } @@ -272,6 +288,13 @@ public final class ResponseCookie extends HttpCookie { */ ResponseCookieBuilder httpOnly(boolean httpOnly); + /** + * Add the "Partitioned" attribute to the cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + ResponseCookieBuilder partitioned(boolean partitioned); + /** * Add the "SameSite" attribute to the cookie. *

This limits the scope of the cookie such that it will only be @@ -397,6 +420,8 @@ public final class ResponseCookie extends HttpCookie { private boolean httpOnly; + private boolean partitioned; + @Nullable private String sameSite; @@ -461,6 +486,12 @@ public final class ResponseCookie extends HttpCookie { return this; } + @Override + public ResponseCookieBuilder partitioned(boolean partitioned) { + this.partitioned = partitioned; + return this; + } + @Override public ResponseCookieBuilder sameSite(@Nullable String sameSite) { this.sameSite = sameSite; @@ -470,7 +501,7 @@ public final class ResponseCookie extends HttpCookie { @Override public ResponseCookie build() { return new ResponseCookie(this.name, this.value, this.maxAge, - this.domain, this.path, this.secure, this.httpOnly, this.sameSite); + this.domain, this.path, this.secure, this.httpOnly, this.partitioned, this.sameSite); } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java index bafd8733b8..1788c72ce7 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorNetty2ServerHttpResponse.java @@ -111,6 +111,7 @@ class ReactorNetty2ServerHttpResponse extends AbstractServerHttpResponse impleme for (ResponseCookie httpCookie : getCookies().get(name)) { Long maxAge = (!httpCookie.getMaxAge().isNegative()) ? httpCookie.getMaxAge().getSeconds() : null; HttpSetCookie.SameSite sameSite = (httpCookie.getSameSite() != null) ? HttpSetCookie.SameSite.valueOf(httpCookie.getSameSite()) : null; + // TODO: support Partitioned attribute when available in Netty 5 API DefaultHttpSetCookie cookie = new DefaultHttpSetCookie(name, httpCookie.getValue(), httpCookie.getPath(), httpCookie.getDomain(), null, maxAge, sameSite, false, httpCookie.isSecure(), httpCookie.isHttpOnly()); this.response.addCookie(cookie); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java index c73ea0dda9..b75ae051ed 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java @@ -120,6 +120,7 @@ class ReactorServerHttpResponse extends AbstractServerHttpResponse implements Ze } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + cookie.setPartitioned(httpCookie.isPartitioned()); if (httpCookie.getSameSite() != null) { cookie.setSameSite(CookieHeaderNames.SameSite.valueOf(httpCookie.getSameSite())); } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java index 2e52b6ed1e..ee96c9143a 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java @@ -39,6 +39,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * Adapt {@link ServerHttpResponse} to the Servlet {@link HttpServletResponse}. @@ -49,6 +50,8 @@ import org.springframework.util.Assert; */ class ServletServerHttpResponse extends AbstractListenerServerHttpResponse { + private static final boolean IS_SERVLET61 = ReflectionUtils.findField(HttpServletResponse.class, "SC_PERMANENT_REDIRECT") != null; + private final HttpServletResponse response; private final ServletOutputStream outputStream; @@ -181,6 +184,14 @@ class ServletServerHttpResponse extends AbstractListenerServerHttpResponse { } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + if (httpCookie.isPartitioned()) { + if (IS_SERVLET61) { + cookie.setAttribute("Partitioned", ""); + } + else { + cookie.setAttribute("Partitioned", "true"); + } + } this.response.addCookie(cookie); } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java index 78272ab3f8..e4382da2a0 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java @@ -122,6 +122,7 @@ class UndertowServerHttpResponse extends AbstractListenerServerHttpResponse impl } cookie.setSecure(httpCookie.isSecure()); cookie.setHttpOnly(httpCookie.isHttpOnly()); + // TODO: add "Partitioned" attribute when Undertow supports it cookie.setSameSiteMode(httpCookie.getSameSite()); this.exchange.setResponseCookie(cookie); } diff --git a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java index 444b0dd26e..e1e19dc6b4 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java @@ -37,12 +37,12 @@ class ResponseCookieTests { assertThat(ResponseCookie.from("id", "1fWa").build().toString()).isEqualTo("id=1fWa"); ResponseCookie cookie = ResponseCookie.from("id", "1fWa") - .domain("abc").path("/path").maxAge(0).httpOnly(true).secure(true).sameSite("None") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite("None") .build(); assertThat(cookie.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + - "Secure; HttpOnly; SameSite=None"); + "Secure; HttpOnly; Partitioned; SameSite=None"); } @Test diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java index 8d2daafa97..4ceee53b45 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java @@ -70,13 +70,32 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests { List cookie0 = splitCookie(headerValues.get(0)); assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue(); assertThat(cookie0.stream().map(String::toLowerCase)) - .containsExactlyInAnyOrder("path=/", "secure", "httponly"); + .contains("path=/", "secure", "httponly"); List cookie1 = splitCookie(headerValues.get(1)); assertThat(cookie1.remove("lang=en-US")).as("lang").isTrue(); assertThat(cookie1.stream().map(String::toLowerCase)) .containsExactlyInAnyOrder("path=/", "domain=example.com"); } + @ParameterizedHttpServerTest + public void partitionedAttributeTest(HttpServer httpServer) throws Exception { + assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow does not support Partitioned cookies"); + startServer(httpServer); + + URI url = URI.create("http://localhost:" + port); + String header = "SID=31d4d96e407aad42; lang=en-US"; + ResponseEntity response = new RestTemplate().exchange( + RequestEntity.get(url).header("Cookie", header).build(), Void.class); + + List headerValues = response.getHeaders().get("Set-Cookie"); + assertThat(headerValues).hasSize(2); + + List cookie0 = splitCookie(headerValues.get(0)); + assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue(); + assertThat(cookie0.stream().map(String::toLowerCase)) + .contains("partitioned"); + } + @ParameterizedHttpServerTest public void cookiesWithSameNameTest(HttpServer httpServer) throws Exception { assumeFalse(httpServer instanceof UndertowHttpServer, "Bug in Undertow in Cookies with same name handling"); @@ -116,7 +135,7 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests { this.requestCookies.size(); // Cause lazy loading response.getCookies().add("SID", ResponseCookie.from("SID", "31d4d96e407aad42") - .path("/").secure(true).httpOnly(true).build()); + .path("/").secure(true).httpOnly(true).partitioned(true).build()); response.getCookies().add("lang", ResponseCookie.from("lang", "en-US") .domain("example.com").path("/").build()); diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java index 8183675a4b..a2680c0ae5 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockCookie.java @@ -98,6 +98,24 @@ public class MockCookie extends Cookie { return getAttribute(SAME_SITE); } + /** + * Set the "Partitioned" attribute for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public void setPartitioned(boolean partitioned) { + setAttribute("Partitioned", ""); + } + + /** + * Return whether the "Partitioned" attribute is set for this cookie. + * @since 6.2 + * @see The Partitioned attribute spec + */ + public boolean isPartitioned() { + return getAttribute("Partitioned") != null; + } + /** * Factory method that parses the value of the supplied "Set-Cookie" header. * @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty @@ -146,6 +164,9 @@ public class MockCookie extends Cookie { else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) { cookie.setComment(extractAttributeValue(attribute, setCookieHeader)); } + else { + cookie.setAttribute(attribute, extractAttributeValue(attribute, setCookieHeader)); + } } return cookie; } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java index b48bd36022..336121698b 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java @@ -481,6 +481,9 @@ public class MockHttpServletResponse implements HttpServletResponse { if (cookie.isHttpOnly()) { buf.append("; HttpOnly"); } + if (cookie.getAttribute("Partitioned") != null) { + buf.append("; Partitioned"); + } if (cookie instanceof MockCookie mockCookie) { if (StringUtils.hasText(mockCookie.getSameSite())) { buf.append("; SameSite=").append(mockCookie.getSameSite());