Test wrapping of response for async request
The following adjustments are also made as a result: - Use int to check if lock is held and unlock is needed, given that for non-async requests we don't need to obtain a lock. - Protect access methods getOutputStream and getWriter with the same locking and state checks. Closes gh-32340
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.web.context.request.async;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import jakarta.servlet.AsyncEvent;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.stubbing.Answer;
|
||||
|
||||
import org.springframework.web.testfixture.servlet.MockAsyncContext;
|
||||
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.BDDMockito.doAnswer;
|
||||
import static org.mockito.BDDMockito.doThrow;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.mock;
|
||||
import static org.mockito.BDDMockito.verify;
|
||||
import static org.mockito.BDDMockito.verifyNoInteractions;
|
||||
|
||||
/**
|
||||
* {@link StandardServletAsyncWebRequest} tests related to response wrapping in
|
||||
* order to enforce thread safety and prevent use after errors.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class AsyncRequestNotUsableTests {
|
||||
|
||||
private final MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
private final HttpServletResponse response = mock();
|
||||
|
||||
private final ServletOutputStream outputStream = mock();
|
||||
|
||||
private final PrintWriter writer = mock();
|
||||
|
||||
private StandardServletAsyncWebRequest asyncRequest;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws IOException {
|
||||
this.request.setAsyncSupported(true);
|
||||
given(this.response.getOutputStream()).willReturn(this.outputStream);
|
||||
given(this.response.getWriter()).willReturn(this.writer);
|
||||
|
||||
this.asyncRequest = new StandardServletAsyncWebRequest(this.request, this.response);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
assertThat(this.asyncRequest.stateLock().isLocked()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
private ServletOutputStream getWrappedOutputStream() throws IOException {
|
||||
return this.asyncRequest.getResponse().getOutputStream();
|
||||
}
|
||||
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
private PrintWriter getWrappedWriter() throws IOException {
|
||||
return this.asyncRequest.getResponse().getWriter();
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
class ResponseTests {
|
||||
|
||||
@Test
|
||||
void notUsableAfterError() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
asyncRequest.onError(new AsyncEvent(new MockAsyncContext(request, response), new Exception()));
|
||||
|
||||
HttpServletResponse wrapped = asyncRequest.getResponse();
|
||||
assertThat(wrapped).isNotNull();
|
||||
assertThatThrownBy(wrapped::getOutputStream).hasMessage("Response not usable after response errors.");
|
||||
assertThatThrownBy(wrapped::getWriter).hasMessage("Response not usable after response errors.");
|
||||
assertThatThrownBy(wrapped::flushBuffer).hasMessage("Response not usable after response errors.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notUsableAfterCompletion() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
asyncRequest.onComplete(new AsyncEvent(new MockAsyncContext(request, response)));
|
||||
|
||||
HttpServletResponse wrapped = asyncRequest.getResponse();
|
||||
assertThat(wrapped).isNotNull();
|
||||
assertThatThrownBy(wrapped::getOutputStream).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::getWriter).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::flushBuffer).hasMessage("Response not usable after async request completion.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notUsableWhenRecreatedAfterCompletion() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
asyncRequest.onComplete(new AsyncEvent(new MockAsyncContext(request, response)));
|
||||
|
||||
StandardServletAsyncWebRequest newWebRequest =
|
||||
new StandardServletAsyncWebRequest(request, response, asyncRequest);
|
||||
|
||||
HttpServletResponse wrapped = newWebRequest.getResponse();
|
||||
assertThat(wrapped).isNotNull();
|
||||
assertThatThrownBy(wrapped::getOutputStream).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::getWriter).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::flushBuffer).hasMessage("Response not usable after async request completion.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
class OutputStreamTests {
|
||||
|
||||
@Test
|
||||
void use() throws IOException {
|
||||
testUseOutputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
void useInAsyncState() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
testUseOutputStream();
|
||||
}
|
||||
|
||||
private void testUseOutputStream() throws IOException {
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
wrapped.write('a');
|
||||
wrapped.write(new byte[0], 1, 2);
|
||||
wrapped.flush();
|
||||
wrapped.close();
|
||||
|
||||
verify(outputStream).write('a');
|
||||
verify(outputStream).write(new byte[0], 1, 2);
|
||||
verify(outputStream).flush();
|
||||
verify(outputStream).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notUsableAfterCompletion() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
asyncRequest.onComplete(new AsyncEvent(new MockAsyncContext(request, response)));
|
||||
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(() -> wrapped.write(new byte[0])).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(() -> wrapped.write(new byte[0], 0, 0)).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::flush).hasMessage("Response not usable after async request completion.");
|
||||
assertThatThrownBy(wrapped::close).hasMessage("Response not usable after async request completion.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockingNotUsed() throws IOException {
|
||||
AtomicInteger count = new AtomicInteger(-1);
|
||||
doAnswer((Answer<Void>) invocation -> {
|
||||
count.set(asyncRequest.stateLock().getHoldCount());
|
||||
return null;
|
||||
}).when(outputStream).write('a');
|
||||
|
||||
// Access ServletOutputStream in NEW state (no async handling) without locking
|
||||
getWrappedOutputStream().write('a');
|
||||
|
||||
assertThat(count.get()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockingUsedInAsyncState() throws IOException {
|
||||
AtomicInteger count = new AtomicInteger(-1);
|
||||
doAnswer((Answer<Void>) invocation -> {
|
||||
count.set(asyncRequest.stateLock().getHoldCount());
|
||||
return null;
|
||||
}).when(outputStream).write('a');
|
||||
|
||||
// Access ServletOutputStream in ASYNC state with locking
|
||||
asyncRequest.startAsync();
|
||||
getWrappedOutputStream().write('a');
|
||||
|
||||
assertThat(count.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
class OutputStreamErrorTests {
|
||||
|
||||
@Test
|
||||
void writeInt() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).write('a');
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("ServletOutputStream failed to write: Broken pipe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBytesFull() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
byte[] bytes = new byte[0];
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).write(bytes, 0, 0);
|
||||
assertThatThrownBy(() -> wrapped.write(bytes)).hasMessage("ServletOutputStream failed to write: Broken pipe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBytes() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
byte[] bytes = new byte[0];
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).write(bytes, 0, 0);
|
||||
assertThatThrownBy(() -> wrapped.write(bytes, 0, 0)).hasMessage("ServletOutputStream failed to write: Broken pipe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flush() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).flush();
|
||||
assertThatThrownBy(wrapped::flush).hasMessage("ServletOutputStream failed to flush: Broken pipe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void close() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).close();
|
||||
assertThatThrownBy(wrapped::close).hasMessage("ServletOutputStream failed to close: Broken pipe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeErrorPreventsFurtherWriting() throws IOException {
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).write('a');
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("ServletOutputStream failed to write: Broken pipe");
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("Response not usable after response errors.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeErrorInAsyncStatePreventsFurtherWriting() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
ServletOutputStream wrapped = getWrappedOutputStream();
|
||||
|
||||
doThrow(new IOException("Broken pipe")).when(outputStream).write('a');
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("ServletOutputStream failed to write: Broken pipe");
|
||||
assertThatThrownBy(() -> wrapped.write('a')).hasMessage("Response not usable after response errors.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Nested
|
||||
class WriterTests {
|
||||
|
||||
@Test
|
||||
void useWriter() throws IOException {
|
||||
testUseWriter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void useWriterInAsyncState() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
testUseWriter();
|
||||
}
|
||||
|
||||
private void testUseWriter() throws IOException {
|
||||
PrintWriter wrapped = getWrappedWriter();
|
||||
|
||||
wrapped.write('a');
|
||||
wrapped.write(new char[0], 1, 2);
|
||||
wrapped.write("abc", 1, 2);
|
||||
wrapped.flush();
|
||||
wrapped.close();
|
||||
|
||||
verify(writer).write('a');
|
||||
verify(writer).write(new char[0], 1, 2);
|
||||
verify(writer).write("abc", 1, 2);
|
||||
verify(writer).flush();
|
||||
verify(writer).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerNotUsableAfterCompletion() throws IOException {
|
||||
asyncRequest.startAsync();
|
||||
PrintWriter wrapped = getWrappedWriter();
|
||||
|
||||
asyncRequest.onComplete(new AsyncEvent(new MockAsyncContext(request, response)));
|
||||
|
||||
char[] chars = new char[0];
|
||||
wrapped.write('a');
|
||||
wrapped.write(chars, 1, 2);
|
||||
wrapped.flush();
|
||||
wrapped.close();
|
||||
|
||||
verifyNoInteractions(writer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockingNotUsed() throws IOException {
|
||||
AtomicInteger count = new AtomicInteger(-1);
|
||||
|
||||
doAnswer((Answer<Void>) invocation -> {
|
||||
count.set(asyncRequest.stateLock().getHoldCount());
|
||||
return null;
|
||||
}).when(writer).write('a');
|
||||
|
||||
// Use Writer in NEW state (no async handling) without locking
|
||||
PrintWriter wrapped = getWrappedWriter();
|
||||
wrapped.write('a');
|
||||
|
||||
assertThat(count.get()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockingUsedInAsyncState() throws IOException {
|
||||
AtomicInteger count = new AtomicInteger(-1);
|
||||
|
||||
doAnswer((Answer<Void>) invocation -> {
|
||||
count.set(asyncRequest.stateLock().getHoldCount());
|
||||
return null;
|
||||
}).when(writer).write('a');
|
||||
|
||||
// Use Writer in ASYNC state with locking
|
||||
asyncRequest.startAsync();
|
||||
PrintWriter wrapped = getWrappedWriter();
|
||||
wrapped.write('a');
|
||||
|
||||
assertThat(count.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class StandardServletAsyncWebRequestTests {
|
||||
this.asyncRequest.setTimeout(44 * 1000L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Nested
|
||||
class StartAsync {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user