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 59f1c395e03..be119c29071 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 08a6200fba1..1f9f77b2f18 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 e29e4e52954..df5bdbd46ef 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 d54278330a8..e387a3b65e8 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 88ed2f04ed1..01a346d36e4 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 1c18645e53a..6a9ee873354 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 6a55669caf7..4f2e7a019be 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 a6f2964ddc2..0bc9975a7c0 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 c3eedbe6456..a8c6ca455bf 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 485550614fc..b65df60254c 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 bafd8733b8d..1788c72ce7e 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 c73ea0dda91..b75ae051ed4 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 2e52b6ed1e9..ee96c9143a6 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 78272ab3f89..e4382da2a0e 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 444b0dd26e2..e1e19dc6b4b 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 8d2daafa972..4ceee53b45c 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