From 75329e6d9d93180d9edceb81838b0d96a145d665 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 24 Feb 2025 14:02:20 +0100 Subject: [PATCH] Revisit MockHttpServletResponse for Servlet 6.1 This commit revisits the behavior of our `MockHttpServletResponse` implementation with the javadoc clarifications applied in Servlet 6.1. Prior to this change, adding or setting an HTTP response header with a `null` name or value would not have the expected behavior: * a `null` name should have no effect instead of throwing exceptions * a `null` value when setting a header effectively removes the entry from the response headers Also, this commit ensures that `IllegalStateException` are thrown if `getWriter` is called after a previous `getOutputStream` (and vice versa). Closes gh-34467 --- .../mock/web/MockHttpServletResponse.java | 49 +- .../web/MockHttpServletResponseTests.java | 1198 +++++++++-------- .../servlet/MockHttpServletResponse.java | 59 +- 3 files changed, 686 insertions(+), 620 deletions(-) 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 2ebda5fbd881..42c05863685c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -97,7 +97,7 @@ public class MockHttpServletResponse implements HttpServletResponse { private final ByteArrayOutputStream content = new ByteArrayOutputStream(1024); - private final ServletOutputStream outputStream = new ResponseServletOutputStream(this.content); + private @Nullable ServletOutputStream outputStream; private @Nullable PrintWriter writer; @@ -258,12 +258,17 @@ public String getCharacterEncoding() { @Override public ServletOutputStream getOutputStream() { Assert.state(this.outputStreamAccessAllowed, "OutputStream access not allowed"); + Assert.state(this.writer == null, "getWriter() has already been called"); + if (this.outputStream == null) { + this.outputStream = new ResponseServletOutputStream(this.content); + } return this.outputStream; } @Override public PrintWriter getWriter() throws UnsupportedEncodingException { Assert.state(this.writerAccessAllowed, "Writer access not allowed"); + Assert.state(this.outputStream == null, "getOutputStream() has already been called"); if (this.writer == null) { Writer targetWriter = new OutputStreamWriter(this.content, getCharacterEncoding()); this.writer = new ResponsePrintWriter(targetWriter); @@ -365,6 +370,9 @@ else if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON) || } updateContentTypePropertyAndHeader(); } + else { + this.headers.remove(HttpHeaders.CONTENT_TYPE); + } } @Override @@ -421,6 +429,8 @@ public void reset() { this.headers.clear(); this.status = HttpServletResponse.SC_OK; this.errorMessage = null; + this.writer = null; + this.outputStream = null; } @Override @@ -680,17 +690,12 @@ private DateFormat newDateFormat() { } @Override - public void setHeader(String name, @Nullable String value) { - if (value == null) { - this.headers.remove(name); - } - else { - setHeaderValue(name, value); - } + public void setHeader(@Nullable String name, @Nullable String value) { + setHeaderValue(name, value); } @Override - public void addHeader(String name, @Nullable String value) { + public void addHeader(@Nullable String name, @Nullable String value) { addHeaderValue(name, value); } @@ -704,8 +709,8 @@ public void addIntHeader(String name, int value) { addHeaderValue(name, value); } - private void setHeaderValue(String name, @Nullable Object value) { - if (value == null) { + private void setHeaderValue(@Nullable String name, @Nullable Object value) { + if (name == null) { return; } boolean replaceHeader = true; @@ -715,8 +720,8 @@ private void setHeaderValue(String name, @Nullable Object value) { doAddHeaderValue(name, value, replaceHeader); } - private void addHeaderValue(String name, @Nullable Object value) { - if (value == null) { + private void addHeaderValue(@Nullable String name, @Nullable Object value) { + if (name == null) { return; } boolean replaceHeader = false; @@ -726,7 +731,19 @@ private void addHeaderValue(String name, @Nullable Object value) { doAddHeaderValue(name, value, replaceHeader); } - private boolean setSpecialHeader(String name, Object value, boolean replaceHeader) { + private boolean setSpecialHeader(String name, @Nullable Object value, boolean replaceHeader) { + if (value == null) { + if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) { + setContentType(null); + } + else if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(name)) { + this.contentLength = 0; + } + else { + this.headers.remove(name); + } + return true; + } if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) { setContentType(value.toString()); return true; @@ -763,7 +780,7 @@ else if (HttpHeaders.SET_COOKIE.equalsIgnoreCase(name)) { } } - private void doAddHeaderValue(String name, Object value, boolean replace) { + private void doAddHeaderValue(String name, @Nullable Object value, boolean replace) { Assert.notNull(value, "Header value must not be null"); HeaderValueHolder header = this.headers.computeIfAbsent(name, key -> new HeaderValueHolder()); if (replace) { 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 131e0696673b..5982639b23ac 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -26,23 +26,19 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.web.util.WebUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.InstanceOfAssertFactories.type; -import static org.assertj.core.api.SoftAssertions.assertSoftly; -import static org.springframework.http.HttpHeaders.CONTENT_LANGUAGE; -import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; -import static org.springframework.http.HttpHeaders.CONTENT_TYPE; -import static org.springframework.http.HttpHeaders.LAST_MODIFIED; -import static org.springframework.http.HttpHeaders.LOCATION; -import static org.springframework.http.HttpHeaders.SET_COOKIE; /** * Tests for {@link MockHttpServletResponse}. @@ -55,590 +51,626 @@ * @author Brian Clozel * @author Sebastien Deleuze * @author Vedran Pavic - * @since 19.02.2006 */ class MockHttpServletResponseTests { private MockHttpServletResponse response = new MockHttpServletResponse(); - - @ParameterizedTest // gh-26488 - @ValueSource(strings = { - CONTENT_TYPE, - CONTENT_LENGTH, - CONTENT_LANGUAGE, - SET_COOKIE, - "enigma" - }) - void addHeaderWithNullValue(String headerName) { - response.addHeader(headerName, null); - assertThat(response.containsHeader(headerName)).isFalse(); - } - - @ParameterizedTest // gh-26488 - @ValueSource(strings = { - CONTENT_TYPE, - CONTENT_LENGTH, - CONTENT_LANGUAGE, - SET_COOKIE, - "enigma" - }) - void setHeaderWithNullValue(String headerName) { - response.setHeader(headerName, null); - assertThat(response.containsHeader(headerName)).isFalse(); - } - - @ParameterizedTest - @ValueSource(strings = { - CONTENT_TYPE, - CONTENT_LANGUAGE, - "X-Test-Header" - }) - void removeHeaderIfNullValue(String headerName) { - response.addHeader(headerName, "test"); - response.setHeader(headerName, null); - assertThat(response.containsHeader(headerName)).isFalse(); - } - - @Test // gh-26493 - void setLocaleWithNullValue() { - assertThat(response.getLocale()).isEqualTo(Locale.getDefault()); - response.setLocale(null); - assertThat(response.getLocale()).isEqualTo(Locale.getDefault()); - } - - @Test - void setContentType() { - String contentType = "test/plain"; - response.setContentType(contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); - } - - @Test - void setContentTypeUTF8() { - String contentType = "test/plain;charset=UTF-8"; - response.setContentType(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - } - - @Test - void contentTypeHeader() { - String contentType = "test/plain"; - response.setHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); - - response = new MockHttpServletResponse(); - response.addHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); - } - - @Test - void contentTypeHeaderUTF8() { - String contentType = "test/plain;charset=UTF-8"; - response.setHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - - response = new MockHttpServletResponse(); - response.addHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - } - - @Test // SPR-12677 - void contentTypeHeaderWithMoreComplexCharsetSyntax() { - String contentType = "test/plain;charset=\"utf-8\";foo=\"charset=bar\";foocharset=bar;foo=bar"; - response.setHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - - response = new MockHttpServletResponse(); - response.addHeader(CONTENT_TYPE, contentType); - assertThat(response.getContentType()).isEqualTo(contentType); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo(contentType); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - } - - @Test // gh-25281 - void contentLanguageHeaderWithSingleValue() { - String contentLanguage = "it"; - response.setHeader(CONTENT_LANGUAGE, contentLanguage); - assertSoftly(softly -> { - softly.assertThat(response.getHeader(CONTENT_LANGUAGE)).isEqualTo(contentLanguage); - softly.assertThat(response.getLocale()).isEqualTo(Locale.ITALIAN); - }); - } - - @Test // gh-25281 - void contentLanguageHeaderWithMultipleValues() { - String contentLanguage = "it, en"; - response.setHeader(CONTENT_LANGUAGE, contentLanguage); - assertSoftly(softly -> { - softly.assertThat(response.getHeader(CONTENT_LANGUAGE)).isEqualTo(contentLanguage); - softly.assertThat(response.getLocale()).isEqualTo(Locale.ITALIAN); - }); - } - - @Test - void setContentTypeThenCharacterEncoding() { - response.setContentType("test/plain"); - response.setCharacterEncoding("UTF-8"); - assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - } - - @Test - void setCharacterEncodingThenContentType() { - response.setCharacterEncoding("UTF-8"); - response.setContentType("test/plain"); - assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - } - - @Test - void setCharacterEncodingNull() { - response.setContentType("test/plain"); - response.setCharacterEncoding("UTF-8"); - assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); - response.setCharacterEncoding((String) null); - assertThat(response.getContentType()).isEqualTo("test/plain"); - assertThat(response.getHeader(CONTENT_TYPE)).isEqualTo("test/plain"); - assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); - } - - @Test - void defaultCharacterEncoding() { - assertThat(response.isCharset()).isFalse(); - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("ISO-8859-1"); - - response.setDefaultCharacterEncoding("UTF-8"); - assertThat(response.isCharset()).isFalse(); - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - - response.setContentType("text/plain;charset=UTF-16"); - assertThat(response.isCharset()).isTrue(); - assertThat(response.getContentType()).isEqualTo("text/plain;charset=UTF-16"); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-16"); - - response.reset(); - assertThat(response.isCharset()).isFalse(); - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - - response.setCharacterEncoding("FOXTROT"); - assertThat(response.isCharset()).isTrue(); - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("FOXTROT"); - - response.setDefaultCharacterEncoding("ENIGMA"); - assertThat(response.getCharacterEncoding()).isEqualTo("FOXTROT"); - } - - @Test - void contentLength() { - response.setContentLength(66); - assertThat(response.getContentLength()).isEqualTo(66); - assertThat(response.getHeader(CONTENT_LENGTH)).isEqualTo("66"); - } - - @Test - void contentLengthHeader() { - response.addHeader(CONTENT_LENGTH, "66"); - assertThat(response.getContentLength()).isEqualTo(66); - assertThat(response.getHeader(CONTENT_LENGTH)).isEqualTo("66"); - } - - @Test - void contentLengthIntHeader() { - response.addIntHeader(CONTENT_LENGTH, 66); - assertThat(response.getContentLength()).isEqualTo(66); - assertThat(response.getHeader(CONTENT_LENGTH)).isEqualTo("66"); - } - - @Test - void httpHeaderNameCasingIsPreserved() { - final String headerName = "Header1"; - response.addHeader(headerName, "value1"); - Collection responseHeaders = response.getHeaderNames(); - assertThat(responseHeaders).containsExactly(headerName); - } - - @Test - void cookies() { - Cookie cookie = new MockCookie("foo", "bar"); - cookie.setPath("/path"); - cookie.setDomain("example.com"); - 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; Partitioned")); - } - - @Test - void servletOutputStreamCommittedWhenBufferSizeExceeded() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getOutputStream().write('X'); - assertThat(response.isCommitted()).isFalse(); - int size = response.getBufferSize(); - response.getOutputStream().write(new byte[size]); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize((size + 1)); - } - - @Test - void servletOutputStreamCommittedOnFlushBuffer() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getOutputStream().write('X'); - assertThat(response.isCommitted()).isFalse(); - response.flushBuffer(); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize(1); - } - - @Test - void servletWriterCommittedWhenBufferSizeExceeded() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getWriter().write("X"); - assertThat(response.isCommitted()).isFalse(); - int size = response.getBufferSize(); - char[] data = new char[size]; - Arrays.fill(data, 'p'); - response.getWriter().write(data); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize((size + 1)); - } - - @Test - void servletOutputStreamCommittedOnOutputStreamFlush() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getOutputStream().write('X'); - assertThat(response.isCommitted()).isFalse(); - response.getOutputStream().flush(); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize(1); - } - - @Test - void servletWriterCommittedOnWriterFlush() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getWriter().write("X"); - assertThat(response.isCommitted()).isFalse(); - response.getWriter().flush(); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize(1); - } - - @Test // SPR-16683 - void servletWriterCommittedOnWriterClose() throws IOException { - assertThat(response.isCommitted()).isFalse(); - response.getWriter().write("X"); - assertThat(response.isCommitted()).isFalse(); - response.getWriter().close(); - assertThat(response.isCommitted()).isTrue(); - assertThat(response.getContentAsByteArray()).hasSize(1); - } - - @Test // gh-23219 - void contentAsUtf8() throws IOException { - String content = "Příliš žluťoučký kůň úpěl ďábelské ódy"; - response.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8)); - assertThat(response.getContentAsString(StandardCharsets.UTF_8)).isEqualTo(content); - } - - @Test - void servletWriterAutoFlushedForChar() throws IOException { - response.getWriter().write('X'); - assertThat(response.getContentAsString()).isEqualTo("X"); - } - - @Test - void servletWriterAutoFlushedForCharArray() throws IOException { - response.getWriter().write("XY".toCharArray()); - assertThat(response.getContentAsString()).isEqualTo("XY"); - } - - @Test - void servletWriterAutoFlushedForString() throws IOException { - response.getWriter().write("X"); - assertThat(response.getContentAsString()).isEqualTo("X"); - } - - @Test - void sendRedirect() throws IOException { - String redirectUrl = "/redirect"; - response.sendRedirect(redirectUrl); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_MOVED_TEMPORARILY); - assertThat(response.getHeader(LOCATION)).isEqualTo(redirectUrl); - assertThat(response.getRedirectedUrl()).isEqualTo(redirectUrl); - assertThat(response.isCommitted()).isTrue(); - } - - @Test - void locationHeaderUpdatesGetRedirectedUrl() { - String redirectUrl = "/redirect"; - response.setHeader(LOCATION, redirectUrl); - assertThat(response.getRedirectedUrl()).isEqualTo(redirectUrl); - } - - @Test - void setDateHeader() { - response.setDateHeader(LAST_MODIFIED, 1437472800000L); - assertThat(response.getHeader(LAST_MODIFIED)).isEqualTo("Tue, 21 Jul 2015 10:00:00 GMT"); - } - - @Test - void addDateHeader() { - response.addDateHeader(LAST_MODIFIED, 1437472800000L); - response.addDateHeader(LAST_MODIFIED, 1437472801000L); - assertThat(response.getHeaders(LAST_MODIFIED)).containsExactly( - "Tue, 21 Jul 2015 10:00:00 GMT", "Tue, 21 Jul 2015 10:00:01 GMT"); - } - - @Test - void getDateHeader() { - long time = 1437472800000L; - response.setDateHeader(LAST_MODIFIED, time); - assertThat(response.getHeader(LAST_MODIFIED)).isEqualTo("Tue, 21 Jul 2015 10:00:00 GMT"); - assertThat(response.getDateHeader(LAST_MODIFIED)).isEqualTo(time); - } - - @Test - void getInvalidDateHeader() { - response.setHeader(LAST_MODIFIED, "invalid"); - assertThat(response.getHeader(LAST_MODIFIED)).isEqualTo("invalid"); - assertThatIllegalArgumentException().isThrownBy(() -> response.getDateHeader(LAST_MODIFIED)); - } - - @Test // SPR-16160 - void getNonExistentDateHeader() { - assertThat(response.getHeader(LAST_MODIFIED)).isNull(); - assertThat(response.getDateHeader(LAST_MODIFIED)).isEqualTo(-1); - } - - @Test // SPR-10414 - void modifyStatusAfterSendError() throws IOException { - response.sendError(HttpServletResponse.SC_NOT_FOUND); - response.setStatus(HttpServletResponse.SC_OK); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); - } - - @Test // SPR-10414 - void modifyStatusMessageAfterSendError() throws IOException { - response.sendError(HttpServletResponse.SC_NOT_FOUND); - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); - } - - /** - * @since 5.1.10 - */ - @Test - void setCookieHeader() { - response.setHeader(SET_COOKIE, "SESSION=123; Path=/; Secure; HttpOnly; SameSite=Lax"); - assertNumCookies(1); - assertPrimarySessionCookie("123"); - - // Setting the Set-Cookie header a 2nd time should overwrite the previous value - response.setHeader(SET_COOKIE, "SESSION=999; Path=/; Secure; HttpOnly; SameSite=Lax"); - assertNumCookies(1); - assertPrimarySessionCookie("999"); - } - - /** - * @since 5.1.11 - */ - @Test - void setCookieHeaderWithMaxAgeAndExpiresAttributes() { - String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; - response.setHeader(SET_COOKIE, cookieValue); - assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); - - assertNumCookies(1); - assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); - MockCookie mockCookie = (MockCookie) response.getCookies()[0]; - assertThat(mockCookie.getMaxAge()).isEqualTo(100); - assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); - } - - /** - * @since 5.1.12 - */ - @Test - void setCookieHeaderWithZeroExpiresAttribute() { - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=0"; - response.setHeader(SET_COOKIE, cookieValue); - assertNumCookies(1); - String header = response.getHeader(SET_COOKIE); - assertThat(header).isNotEqualTo(cookieValue); - // We don't assert the actual Expires value since it is based on the current time. - assertThat(header).startsWith("SESSION=123; Path=/; Max-Age=100; Expires="); - } - - @Test - void addCookieHeader() { - response.addHeader(SET_COOKIE, "SESSION=123; Path=/; Secure; HttpOnly; SameSite=Lax"); - assertNumCookies(1); - assertPrimarySessionCookie("123"); - - // Adding a 2nd cookie header should result in 2 cookies. - response.addHeader(SET_COOKIE, "SESSION=999; Path=/; Secure; HttpOnly; SameSite=Lax"); - assertNumCookies(2); - assertPrimarySessionCookie("123"); - assertCookieValues("123", "999"); - } - - /** - * @since 5.1.11 - */ - @Test - void addCookieHeaderWithMaxAgeAndExpiresAttributes() { - String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; - response.addHeader(SET_COOKIE, cookieValue); - assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); - - assertNumCookies(1); - assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); - MockCookie mockCookie = (MockCookie) response.getCookies()[0]; - assertThat(mockCookie.getMaxAge()).isEqualTo(100); - assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); - } - - /** - * @since 5.1.12 - */ - @Test - void addCookieHeaderWithMaxAgeAndZeroExpiresAttributes() { - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=0"; - response.addHeader(SET_COOKIE, cookieValue); - assertNumCookies(1); - String header = response.getHeader(SET_COOKIE); - assertThat(header).isNotEqualTo(cookieValue); - // We don't assert the actual Expires value since it is based on the current time. - assertThat(header).startsWith("SESSION=123; Path=/; Max-Age=100; Expires="); - } - - /** - * @since 5.2.14 - */ - @Test - void addCookieHeaderWithExpiresAttributeWithoutMaxAgeAttribute() { - String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; - String cookieValue = "SESSION=123; Path=/; Expires=" + expiryDate; - response.addHeader(SET_COOKIE, cookieValue); - assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); - - assertNumCookies(1); - assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); - MockCookie mockCookie = (MockCookie) response.getCookies()[0]; - assertThat(mockCookie.getName()).isEqualTo("SESSION"); - assertThat(mockCookie.getValue()).isEqualTo("123"); - assertThat(mockCookie.getPath()).isEqualTo("/"); - assertThat(mockCookie.getMaxAge()).isEqualTo(-1); - assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); - } - - @Test - void addCookie() { - MockCookie mockCookie = new MockCookie("SESSION", "123"); - mockCookie.setPath("/"); - mockCookie.setDomain("example.com"); - mockCookie.setMaxAge(0); - mockCookie.setSecure(true); - mockCookie.setHttpOnly(true); - mockCookie.setSameSite("Lax"); - - response.addCookie(mockCookie); - - assertNumCookies(1); - assertThat(response.getHeader(SET_COOKIE)).isEqualTo(("SESSION=123; Path=/; Domain=example.com; Max-Age=0; " + - "Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Lax")); - - // Adding a 2nd Cookie should result in 2 Cookies. - response.addCookie(new MockCookie("SESSION", "999")); - assertNumCookies(2); - assertCookieValues("123", "999"); - } - - private void assertNumCookies(int expected) { - assertThat(this.response.getCookies()).hasSize(expected); - } - - private void assertCookieValues(String... expected) { - assertThat(response.getCookies()).extracting(Cookie::getValue).containsExactly(expected); - } - - @SuppressWarnings("removal") - private void assertPrimarySessionCookie(String expectedValue) { - Cookie cookie = this.response.getCookie("SESSION"); - assertThat(cookie).asInstanceOf(type(MockCookie.class)).satisfies(mockCookie -> { + @Nested + class CharacterEncodingTests { + + @Test + void isoShouldBeDefault() { + assertThat(response.isCharset()).isFalse(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + } + + @Test + void shouldSetDefault() { + response.setDefaultCharacterEncoding("UTF-8"); + assertThat(response.isCharset()).isFalse(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void shouldResetToDefault() { + response.setDefaultCharacterEncoding("UTF-8"); + response.setCharacterEncoding(WebUtils.DEFAULT_CHARACTER_ENCODING); + + response.reset(); + assertThat(response.isCharset()).isFalse(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void setDefaultShouldNotChangeEncoding() { + response.setCharacterEncoding("UTF-16"); + assertThat(response.isCharset()).isTrue(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-16"); + + response.setDefaultCharacterEncoding("UTF-8"); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-16"); + } + + @Test + void shouldSetEncodingWithContentType() { + String contentType = "text/plain;charset=UTF-8"; + response.setContentType(contentType); + assertThat(response.isCharset()).isTrue(); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void shouldSetUtf8EncodingForJson() { + String contentType = "application/json"; + response.setContentType(contentType); + assertThat(response.isCharset()).isFalse(); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test // SPR-12677 + void shouldSetEncodingWithComplexContentTypeSyntax() { + String contentType = "test/plain;charset=\"utf-8\";foo=\"charset=bar\";foocharset=bar;foo=bar"; + response.setHeader(HttpHeaders.CONTENT_TYPE, contentType); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void setContentTypeThenCharacterEncoding() { + response.setContentType("test/plain"); + response.setCharacterEncoding("UTF-8"); + assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void setCharacterEncodingThenContentType() { + response.setCharacterEncoding("UTF-8"); + response.setContentType("test/plain"); + assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + } + + @Test + void setCharacterEncodingNull() { + response.setContentType("test/plain"); + response.setCharacterEncoding("UTF-8"); + assertThat(response.getContentType()).isEqualTo("test/plain;charset=UTF-8"); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("test/plain;charset=UTF-8"); + response.setCharacterEncoding((String) null); + assertThat(response.getContentType()).isEqualTo("test/plain"); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("test/plain"); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + } + + @Test // gh-25501 + void resetResponseShouldResetCharset() { + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + assertThat(response.isCharset()).isFalse(); + response.setCharacterEncoding("UTF-8"); + assertThat(response.isCharset()).isTrue(); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + response.setContentType("text/plain"); + assertThat(response.getContentType()).isEqualTo("text/plain;charset=UTF-8"); + String contentTypeHeader = response.getHeader(HttpHeaders.CONTENT_TYPE); + assertThat(contentTypeHeader).isEqualTo("text/plain;charset=UTF-8"); + + response.reset(); + + assertThat(response.getContentType()).isNull(); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + assertThat(response.isCharset()).isFalse(); + // Do not invoke setCharacterEncoding() since that sets the charset flag to true. + // response.setCharacterEncoding("UTF-8"); + response.setContentType("text/plain"); + assertThat(response.isCharset()).isFalse(); // should still be false + assertThat(response.getContentType()).isEqualTo("text/plain"); + contentTypeHeader = response.getHeader(HttpHeaders.CONTENT_TYPE); + assertThat(contentTypeHeader).isEqualTo("text/plain"); + } + + } + + + @Nested + class HeadersTests { + + @ParameterizedTest // gh-26488 + @ValueSource(strings = { + HttpHeaders.CONTENT_TYPE, + HttpHeaders.CONTENT_LENGTH, + HttpHeaders.CONTENT_LANGUAGE, + HttpHeaders.SET_COOKIE, + "X-Test" + }) + void addHeaderWithNullValueShouldHaveNoEffect(String headerName) { + response.addHeader(headerName, null); + assertThat(response.containsHeader(headerName)).isFalse(); + } + + @Test + void addHeaderWithNullNameShouldHaveNoEffect() { + response.addHeader(null, "test"); + assertThat(response.getHeaderNames()).isEmpty(); + } + + @ParameterizedTest // gh-26488 + @ValueSource(strings = { + HttpHeaders.CONTENT_TYPE, + HttpHeaders.CONTENT_LENGTH, + HttpHeaders.CONTENT_LANGUAGE, + HttpHeaders.SET_COOKIE, + "X-Test" + }) + void setHeaderWithNullValueShouldHaveNoEffect(String headerName) { + response.setHeader(headerName, null); + assertThat(response.containsHeader(headerName)).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { + HttpHeaders.CONTENT_LANGUAGE, + "X-Test-Header" + }) + void setHeaderWithNullValueShouldRemoveHeader(String headerName) { + response.addHeader(headerName, "test"); + response.setHeader(headerName, null); + assertThat(response.containsHeader(headerName)).isFalse(); + } + + @Test + void shouldSetContentType() { + String contentType = "text/plain"; + response.setContentType(contentType); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + } + + @Test + void shouldSetContentTypeHeader() { + String contentType = "text/plain"; + response.setHeader(HttpHeaders.CONTENT_TYPE, contentType); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + } + + @Test + void shouldAddContentTypeHeader() { + String contentType = "text/plain"; + response.addHeader(HttpHeaders.CONTENT_TYPE, contentType); + assertThat(response.getContentType()).isEqualTo(contentType); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(contentType); + assertThat(response.getCharacterEncoding()).isEqualTo(WebUtils.DEFAULT_CHARACTER_ENCODING); + } + + @Test + void setContentTypeWithNullValueShouldRemoveHeader() { + response.setContentType("application/json"); + response.setContentType(null); + assertThat(response.containsHeader("Content-Type")).isFalse(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isNull(); + } + + @Test + void setContentTypeHeaderWithNullValueShouldRemoveHeader() { + response.setContentType("application/json"); + response.setHeader(HttpHeaders.CONTENT_TYPE, null); + assertThat(response.containsHeader("Content-Type")).isFalse(); + assertThat(response.getContentType()).isNull(); + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isNull(); + } + + @Test // gh-25281 + void contentLanguageHeaderWithSingleValue() { + String contentLanguage = "it"; + response.setHeader(HttpHeaders.CONTENT_LANGUAGE, contentLanguage); + assertThat(response.getHeader(HttpHeaders.CONTENT_LANGUAGE)).isEqualTo(contentLanguage); + assertThat(response.getLocale()).isEqualTo(Locale.ITALIAN); + } + + @Test // gh-25281 + void contentLanguageHeaderWithMultipleValues() { + String contentLanguage = "it, en"; + response.setHeader(HttpHeaders.CONTENT_LANGUAGE, contentLanguage); + assertThat(response.getHeader(HttpHeaders.CONTENT_LANGUAGE)).isEqualTo(contentLanguage); + assertThat(response.getLocale()).isEqualTo(Locale.ITALIAN); + } + + @Test + void contentLengthSetsHeader() { + response.setContentLength(66); + assertThat(response.getContentLength()).isEqualTo(66); + assertThat(response.getHeader(HttpHeaders.CONTENT_LENGTH)).isEqualTo("66"); + } + + @Test + void contentLengthHeaderSetsLength() { + response.addHeader(HttpHeaders.CONTENT_LENGTH, "66"); + assertThat(response.getContentLength()).isEqualTo(66); + assertThat(response.getHeader(HttpHeaders.CONTENT_LENGTH)).isEqualTo("66"); + } + + @Test + void contentLengthIntHeader() { + response.addIntHeader(HttpHeaders.CONTENT_LENGTH, 66); + assertThat(response.getContentLength()).isEqualTo(66); + assertThat(response.getHeader(HttpHeaders.CONTENT_LENGTH)).isEqualTo("66"); + } + + @Test + void httpHeaderNameCasingIsPreserved() { + final String headerName = "Header1"; + response.addHeader(headerName, "value1"); + Collection responseHeaders = response.getHeaderNames(); + assertThat(responseHeaders).containsExactly(headerName); + } + + @Test + void setDateHeader() { + response.setDateHeader(HttpHeaders.LAST_MODIFIED, 1437472800000L); + assertThat(response.getHeader(HttpHeaders.LAST_MODIFIED)).isEqualTo("Tue, 21 Jul 2015 10:00:00 GMT"); + } + + @Test + void addDateHeader() { + response.addDateHeader(HttpHeaders.LAST_MODIFIED, 1437472800000L); + response.addDateHeader(HttpHeaders.LAST_MODIFIED, 1437472801000L); + assertThat(response.getHeaders(HttpHeaders.LAST_MODIFIED)).containsExactly( + "Tue, 21 Jul 2015 10:00:00 GMT", "Tue, 21 Jul 2015 10:00:01 GMT"); + } + + @Test + void getDateHeader() { + long time = 1437472800000L; + response.setDateHeader(HttpHeaders.LAST_MODIFIED, time); + assertThat(response.getHeader(HttpHeaders.LAST_MODIFIED)).isEqualTo("Tue, 21 Jul 2015 10:00:00 GMT"); + assertThat(response.getDateHeader(HttpHeaders.LAST_MODIFIED)).isEqualTo(time); + } + + @Test + void getInvalidDateHeader() { + response.setHeader(HttpHeaders.LAST_MODIFIED, "invalid"); + assertThat(response.getHeader(HttpHeaders.LAST_MODIFIED)).isEqualTo("invalid"); + assertThatIllegalArgumentException().isThrownBy(() -> response.getDateHeader(HttpHeaders.LAST_MODIFIED)); + } + + @Test // SPR-16160 + void getNonExistentDateHeader() { + assertThat(response.getHeader(HttpHeaders.LAST_MODIFIED)).isNull(); + assertThat(response.getDateHeader(HttpHeaders.LAST_MODIFIED)).isEqualTo(-1); + } + + } + + @Nested + class CookiesTests { + + @Test + void cookies() { + Cookie cookie = new MockCookie("foo", "bar"); + cookie.setPath("/path"); + cookie.setDomain("example.com"); + cookie.setMaxAge(0); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setAttribute("Partitioned", ""); + + response.addCookie(cookie); + + assertThat(response.getHeader(HttpHeaders.SET_COOKIE)).isEqualTo(("foo=bar; Path=/path; Domain=example.com; " + + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + + "Secure; HttpOnly; Partitioned")); + } + + @Test + void setCookieHeader() { + response.setHeader(HttpHeaders.SET_COOKIE, "SESSION=123; Path=/; Secure; HttpOnly; SameSite=Lax"); + assertNumCookies(1); + assertPrimarySessionCookie("123"); + + // Setting the Set-Cookie header a 2nd time should overwrite the previous value + response.setHeader(HttpHeaders.SET_COOKIE, "SESSION=999; Path=/; Secure; HttpOnly; SameSite=Lax"); + assertNumCookies(1); + assertPrimarySessionCookie("999"); + } + + @Test + void setCookieHeaderWithMaxAgeAndExpiresAttributes() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; + response.setHeader(HttpHeaders.SET_COOKIE, cookieValue); + assertThat(response.getHeader(HttpHeaders.SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; + assertThat(mockCookie.getMaxAge()).isEqualTo(100); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); + } + + @Test + void setCookieHeaderWithZeroExpiresAttribute() { + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=0"; + response.setHeader(HttpHeaders.SET_COOKIE, cookieValue); + assertNumCookies(1); + String header = response.getHeader(HttpHeaders.SET_COOKIE); + assertThat(header).isNotEqualTo(cookieValue); + // We don't assert the actual Expires value since it is based on the current time. + assertThat(header).startsWith("SESSION=123; Path=/; Max-Age=100; Expires="); + } + + @Test + void addCookieHeader() { + response.addHeader(HttpHeaders.SET_COOKIE, "SESSION=123; Path=/; Secure; HttpOnly; SameSite=Lax"); + assertNumCookies(1); + assertPrimarySessionCookie("123"); + + // Adding a 2nd cookie header should result in 2 cookies. + response.addHeader(HttpHeaders.SET_COOKIE, "SESSION=999; Path=/; Secure; HttpOnly; SameSite=Lax"); + assertNumCookies(2); + assertPrimarySessionCookie("123"); + assertCookieValues("123", "999"); + } + + @Test + void addCookieHeaderWithMaxAgeAndExpiresAttributes() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; + response.addHeader(HttpHeaders.SET_COOKIE, cookieValue); + assertThat(response.getHeader(HttpHeaders.SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; + assertThat(mockCookie.getMaxAge()).isEqualTo(100); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); + } + + @Test + void addCookieHeaderWithMaxAgeAndZeroExpiresAttributes() { + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=0"; + response.addHeader(HttpHeaders.SET_COOKIE, cookieValue); + assertNumCookies(1); + String header = response.getHeader(HttpHeaders.SET_COOKIE); + assertThat(header).isNotEqualTo(cookieValue); + // We don't assert the actual Expires value since it is based on the current time. + assertThat(header).startsWith("SESSION=123; Path=/; Max-Age=100; Expires="); + } + + @Test + void addCookieHeaderWithExpiresAttributeWithoutMaxAgeAttribute() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Expires=" + expiryDate; + response.addHeader(HttpHeaders.SET_COOKIE, cookieValue); + assertThat(response.getHeader(HttpHeaders.SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; assertThat(mockCookie.getName()).isEqualTo("SESSION"); - assertThat(mockCookie.getValue()).isEqualTo(expectedValue); + assertThat(mockCookie.getValue()).isEqualTo("123"); assertThat(mockCookie.getPath()).isEqualTo("/"); - assertThat(mockCookie.getSecure()).isTrue(); - assertThat(mockCookie.isHttpOnly()).isTrue(); - assertThat(mockCookie.getComment()).isNull(); - assertThat(mockCookie.getExpires()).isNull(); - assertThat(mockCookie.getSameSite()).isEqualTo("Lax"); - }); - } + assertThat(mockCookie.getMaxAge()).isEqualTo(-1); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); + } + + @Test + void addCookie() { + MockCookie mockCookie = new MockCookie("SESSION", "123"); + mockCookie.setPath("/"); + mockCookie.setDomain("example.com"); + mockCookie.setMaxAge(0); + mockCookie.setSecure(true); + mockCookie.setHttpOnly(true); + mockCookie.setSameSite("Lax"); + + response.addCookie(mockCookie); + + assertNumCookies(1); + assertThat(response.getHeader(HttpHeaders.SET_COOKIE)).isEqualTo(("SESSION=123; Path=/; Domain=example.com; Max-Age=0; " + + "Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Lax")); + + // Adding a 2nd Cookie should result in 2 Cookies. + response.addCookie(new MockCookie("SESSION", "999")); + assertNumCookies(2); + assertCookieValues("123", "999"); + } + + private void assertNumCookies(int expected) { + assertThat(response.getCookies()).hasSize(expected); + } + + private void assertCookieValues(String... expected) { + assertThat(response.getCookies()).extracting(Cookie::getValue).containsExactly(expected); + } + + @SuppressWarnings("removal") + private void assertPrimarySessionCookie(String expectedValue) { + Cookie cookie = response.getCookie("SESSION"); + assertThat(cookie).asInstanceOf(type(MockCookie.class)).satisfies(mockCookie -> { + assertThat(mockCookie.getName()).isEqualTo("SESSION"); + assertThat(mockCookie.getValue()).isEqualTo(expectedValue); + assertThat(mockCookie.getPath()).isEqualTo("/"); + assertThat(mockCookie.getSecure()).isTrue(); + assertThat(mockCookie.isHttpOnly()).isTrue(); + assertThat(mockCookie.getComment()).isNull(); + assertThat(mockCookie.getExpires()).isNull(); + assertThat(mockCookie.getSameSite()).isEqualTo("Lax"); + }); + } + + } + + @Nested + class ResponseCommittedTests { + + @Test + void servletOutputStreamCommittedWhenBufferSizeExceeded() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getOutputStream().write('X'); + assertThat(response.isCommitted()).isFalse(); + int size = response.getBufferSize(); + response.getOutputStream().write(new byte[size]); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize((size + 1)); + } + + @Test + void servletOutputStreamCommittedOnFlushBuffer() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getOutputStream().write('X'); + assertThat(response.isCommitted()).isFalse(); + response.flushBuffer(); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize(1); + } + + @Test + void servletWriterCommittedWhenBufferSizeExceeded() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getWriter().write("X"); + assertThat(response.isCommitted()).isFalse(); + int size = response.getBufferSize(); + char[] data = new char[size]; + Arrays.fill(data, 'p'); + response.getWriter().write(data); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize((size + 1)); + } + + @Test + void servletOutputStreamCommittedOnOutputStreamFlush() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getOutputStream().write('X'); + assertThat(response.isCommitted()).isFalse(); + response.getOutputStream().flush(); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize(1); + } + + @Test + void servletWriterCommittedOnWriterFlush() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getWriter().write("X"); + assertThat(response.isCommitted()).isFalse(); + response.getWriter().flush(); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize(1); + } + + @Test // SPR-16683 + void servletWriterCommittedOnWriterClose() throws IOException { + assertThat(response.isCommitted()).isFalse(); + response.getWriter().write("X"); + assertThat(response.isCommitted()).isFalse(); + response.getWriter().close(); + assertThat(response.isCommitted()).isTrue(); + assertThat(response.getContentAsByteArray()).hasSize(1); + } + + } + + @Nested + class ResponseBodyTests { + + @Test // gh-26493 + void setLocaleWithNullValue() { + assertThat(response.getLocale()).isEqualTo(Locale.getDefault()); + response.setLocale(null); + assertThat(response.getLocale()).isEqualTo(Locale.getDefault()); + } + + @Test // gh-23219 + void contentAsUtf8() throws IOException { + String content = "Příliš žluťoučký kůň úpěl ďábelské ódy"; + response.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8)); + assertThat(response.getContentAsString(StandardCharsets.UTF_8)).isEqualTo(content); + } + + @Test + void servletWriterAutoFlushedForChar() throws IOException { + response.getWriter().write('X'); + assertThat(response.getContentAsString()).isEqualTo("X"); + } + + @Test + void servletWriterAutoFlushedForCharArray() throws IOException { + response.getWriter().write("XY".toCharArray()); + assertThat(response.getContentAsString()).isEqualTo("XY"); + } + + @Test + void servletWriterAutoFlushedForString() throws IOException { + response.getWriter().write("X"); + assertThat(response.getContentAsString()).isEqualTo("X"); + } + + @Test + void sendRedirect() throws IOException { + String redirectUrl = "/redirect"; + response.sendRedirect(redirectUrl); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_MOVED_TEMPORARILY); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(redirectUrl); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectUrl); + assertThat(response.isCommitted()).isTrue(); + } + + @Test + void locationHeaderUpdatesGetRedirectedUrl() { + String redirectUrl = "/redirect"; + response.setHeader(HttpHeaders.LOCATION, redirectUrl); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectUrl); + } + + @Test // SPR-10414 + void modifyStatusAfterSendError() throws IOException { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + response.setStatus(HttpServletResponse.SC_OK); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + + @Test // SPR-10414 + void modifyStatusMessageAfterSendError() throws IOException { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + + @Test // gh-33019 + void contentAsStringEncodingWithJson() throws IOException { + String content = "{\"name\": \"Jürgen\"}"; + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(content); + assertThat(response.getContentAsString()).isEqualTo(content); + } + + @Test + void writerShouldFailWhenOutputStreamCalled() { + response.getOutputStream(); + assertThatIllegalStateException().isThrownBy(() -> response.getWriter()); + } + + @Test + void outputStreamShouldFailWhenWriterCalled() throws IOException { + response.getWriter(); + assertThatIllegalStateException().isThrownBy(() -> response.getOutputStream()); + } - @Test // gh-25501 - void resetResetsCharset() { - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("ISO-8859-1"); - assertThat(response.isCharset()).isFalse(); - response.setCharacterEncoding("UTF-8"); - assertThat(response.isCharset()).isTrue(); - assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); - response.setContentType("text/plain"); - assertThat(response.getContentType()).isEqualTo("text/plain;charset=UTF-8"); - String contentTypeHeader = response.getHeader(CONTENT_TYPE); - assertThat(contentTypeHeader).isEqualTo("text/plain;charset=UTF-8"); - - response.reset(); - - assertThat(response.getContentType()).isNull(); - assertThat(response.getCharacterEncoding()).isEqualTo("ISO-8859-1"); - assertThat(response.isCharset()).isFalse(); - // Do not invoke setCharacterEncoding() since that sets the charset flag to true. - // response.setCharacterEncoding("UTF-8"); - response.setContentType("text/plain"); - assertThat(response.isCharset()).isFalse(); // should still be false - assertThat(response.getContentType()).isEqualTo("text/plain"); - contentTypeHeader = response.getHeader(CONTENT_TYPE); - assertThat(contentTypeHeader).isEqualTo("text/plain"); } - @Test // gh-33019 - void contentAsStringEncodingWithJson() throws IOException { - String content = "{\"name\": \"Jürgen\"}"; - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(content); - assertThat(response.getContentAsString()).isEqualTo(content); - } } 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 f4c20df40ab9..ea204211e55a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -97,7 +97,7 @@ public class MockHttpServletResponse implements HttpServletResponse { private final ByteArrayOutputStream content = new ByteArrayOutputStream(1024); - private final ServletOutputStream outputStream = new ResponseServletOutputStream(this.content); + private @Nullable ServletOutputStream outputStream; private @Nullable PrintWriter writer; @@ -222,7 +222,7 @@ private void setExplicitCharacterEncoding(@Nullable String characterEncoding) { } catch (Exception ignored) { String value = this.contentType; - int charsetIndex = value.toLowerCase().indexOf(CHARSET_PREFIX); + int charsetIndex = value.toLowerCase(Locale.ROOT).indexOf(CHARSET_PREFIX); if (charsetIndex != -1) { value = value.substring(0, charsetIndex).trim(); if (value.endsWith(";")) { @@ -242,7 +242,7 @@ private void setExplicitCharacterEncoding(@Nullable String characterEncoding) { private void updateContentTypePropertyAndHeader() { if (this.contentType != null) { String value = this.contentType; - if (this.characterEncodingSet && !value.toLowerCase().contains(CHARSET_PREFIX)) { + if (this.characterEncodingSet && !value.toLowerCase(Locale.ROOT).contains(CHARSET_PREFIX)) { value += ';' + CHARSET_PREFIX + getCharacterEncoding(); this.contentType = value; } @@ -258,12 +258,17 @@ public String getCharacterEncoding() { @Override public ServletOutputStream getOutputStream() { Assert.state(this.outputStreamAccessAllowed, "OutputStream access not allowed"); + Assert.state(this.writer == null, "getWriter() has already been called"); + if (this.outputStream == null) { + this.outputStream = new ResponseServletOutputStream(this.content); + } return this.outputStream; } @Override public PrintWriter getWriter() throws UnsupportedEncodingException { Assert.state(this.writerAccessAllowed, "Writer access not allowed"); + Assert.state(this.outputStream == null, "getOutputStream() has already been called"); if (this.writer == null) { Writer targetWriter = new OutputStreamWriter(this.content, getCharacterEncoding()); this.writer = new ResponsePrintWriter(targetWriter); @@ -353,18 +358,21 @@ public void setContentType(@Nullable String contentType) { } else if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON) || mediaType.isCompatibleWith(APPLICATION_PLUS_JSON)) { - this.characterEncoding = StandardCharsets.UTF_8.name(); + this.characterEncoding = StandardCharsets.UTF_8.name(); } } catch (Exception ex) { // Try to get charset value anyway - int charsetIndex = contentType.toLowerCase().indexOf(CHARSET_PREFIX); + int charsetIndex = contentType.toLowerCase(Locale.ROOT).indexOf(CHARSET_PREFIX); if (charsetIndex != -1) { setExplicitCharacterEncoding(contentType.substring(charsetIndex + CHARSET_PREFIX.length())); } } updateContentTypePropertyAndHeader(); } + else { + this.headers.remove(HttpHeaders.CONTENT_TYPE); + } } @Override @@ -421,6 +429,8 @@ public void reset() { this.headers.clear(); this.status = HttpServletResponse.SC_OK; this.errorMessage = null; + this.writer = null; + this.outputStream = null; } @Override @@ -632,7 +642,7 @@ public void sendRedirect(String url) throws IOException { sendRedirect(url, HttpServletResponse.SC_MOVED_TEMPORARILY, true); } - @Override + // @Override - on Servlet 6.1 public void sendRedirect(String url, int sc, boolean clearBuffer) throws IOException { Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); Assert.notNull(url, "Redirect URL must not be null"); @@ -680,17 +690,12 @@ private DateFormat newDateFormat() { } @Override - public void setHeader(String name, @Nullable String value) { - if (value == null) { - this.headers.remove(name); - } - else { - setHeaderValue(name, value); - } + public void setHeader(@Nullable String name, @Nullable String value) { + setHeaderValue(name, value); } @Override - public void addHeader(String name, @Nullable String value) { + public void addHeader(@Nullable String name, @Nullable String value) { addHeaderValue(name, value); } @@ -704,8 +709,8 @@ public void addIntHeader(String name, int value) { addHeaderValue(name, value); } - private void setHeaderValue(String name, @Nullable Object value) { - if (value == null) { + private void setHeaderValue(@Nullable String name, @Nullable Object value) { + if (name == null) { return; } boolean replaceHeader = true; @@ -715,8 +720,8 @@ private void setHeaderValue(String name, @Nullable Object value) { doAddHeaderValue(name, value, replaceHeader); } - private void addHeaderValue(String name, @Nullable Object value) { - if (value == null) { + private void addHeaderValue(@Nullable String name, @Nullable Object value) { + if (name == null) { return; } boolean replaceHeader = false; @@ -726,7 +731,19 @@ private void addHeaderValue(String name, @Nullable Object value) { doAddHeaderValue(name, value, replaceHeader); } - private boolean setSpecialHeader(String name, Object value, boolean replaceHeader) { + private boolean setSpecialHeader(String name, @Nullable Object value, boolean replaceHeader) { + if (value == null) { + if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) { + setContentType(null); + } + else if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(name)) { + this.contentLength = 0; + } + else { + this.headers.remove(name); + } + return true; + } if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) { setContentType(value.toString()); return true; @@ -763,7 +780,7 @@ else if (HttpHeaders.SET_COOKIE.equalsIgnoreCase(name)) { } } - private void doAddHeaderValue(String name, Object value, boolean replace) { + private void doAddHeaderValue(String name, @Nullable Object value, boolean replace) { Assert.notNull(value, "Header value must not be null"); HeaderValueHolder header = this.headers.computeIfAbsent(name, key -> new HeaderValueHolder()); if (replace) {