From 1588c1928758a9f3dc4894189c1f1f12cbe467a8 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 5 Nov 2026 13:48:09 -0500 Subject: [PATCH] Fix dispatcher GrailsWebRequest publication after late filters --- grails-test-examples/issue-15819/README.md | 44 +++ grails-test-examples/issue-15819/build.gradle | 44 +++ .../grails-app/conf/application.yml | 23 ++ .../grails-app/conf/spring/resources.groovy | 30 ++ .../issue15819/SearchController.groovy | 35 ++ .../controllers/issue15819/UrlMappings.groovy | 26 ++ .../init/issue15819/Application.groovy | 32 ++ .../grails-app/views/search/_results.gsp | 23 ++ .../ResponseRendererFunctionalSpec.groovy | 72 ++++ grails-web-mvc/build.gradle | 2 + .../mvc/GrailsDispatcherServlet.groovy | 24 +- ...DispatcherServletRequestBindingSpec.groovy | 362 ++++++++++++++++++ settings.gradle | 2 + 13 files changed, 709 insertions(+), 10 deletions(-) create mode 100644 grails-test-examples/issue-15819/README.md create mode 100644 grails-test-examples/issue-15819/build.gradle create mode 100644 grails-test-examples/issue-15819/grails-app/conf/application.yml create mode 100644 grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy create mode 100644 grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy create mode 100644 grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy create mode 100644 grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy create mode 100644 grails-test-examples/issue-15819/grails-app/views/search/_results.gsp create mode 100644 grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy create mode 100644 grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy diff --git a/grails-test-examples/issue-15819/README.md b/grails-test-examples/issue-15819/README.md new file mode 100644 index 00000000000..bb9abae30b4 --- /dev/null +++ b/grails-test-examples/issue-15819/README.md @@ -0,0 +1,44 @@ + + +# Response rendering regression (#15819) + +Functional coverage based on the +[end-application reproducer](https://github.com/ruthst00/15819-response-renderer-overloads). +The application registers a Spring `RequestContextFilter` at order `-100`, after +Grails' request filter, and serves a small GSP fragment from `/search/results`. +Keep the fragment small and do not flush the response: committing it can mask +the unwanted conventional view resolution that caused the original failure. + +There is deliberately no `results.gsp` or `upload.gsp`. The tests cover template +rendering with AJAX and normal GET requests and multipart uploads behind a late +request-context filter. `/search/upload` reads a `title` form field and a `file` +upload, and renders the title, original filename and uploaded content in the fragment. + +`GrailsDispatcherServletRequestBindingSpec` provides the dispatcher regression +coverage on `7.0.x`, including multipart resolution, request publication, nested +dispatches and asynchronous processing. It has a distinct name so merge-up can +retain the existing dispatcher and hidden-method specs on `8.0.x`. + +The tests use real HTTP requests against the embedded application; no browser, +database, or Docker container is needed. From the repository root, run: + +```bash +./gradlew :grails-test-examples-issue-15819:integrationTest +``` diff --git a/grails-test-examples/issue-15819/build.gradle b/grails-test-examples/issue-15819/build.gradle new file mode 100644 index 00000000000..34ceb195574 --- /dev/null +++ b/grails-test-examples/issue-15819/build.gradle @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +plugins { + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.vulnerability-scan' +} + +version = '0.1' +group = 'issue15819' + +apply plugin: 'org.apache.grails.gradle.grails-web' +apply plugin: 'org.apache.grails.gradle.grails-gsp' + +dependencies { + implementation platform(project(':grails-bom')) + implementation 'org.apache.grails:grails-dependencies-starter-web' + + integrationTestImplementation 'org.apache.grails.testing:grails-testing-support-core' + integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') +} diff --git a/grails-test-examples/issue-15819/grails-app/conf/application.yml b/grails-test-examples/issue-15819/grails-app/conf/application.yml new file mode 100644 index 00000000000..238a2ce1047 --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/conf/application.yml @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +grails: + profile: web + views: + gsp: + encoding: UTF-8 +info: + app: + name: issue-15819 diff --git a/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy b/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy new file mode 100644 index 00000000000..866b97d5561 --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 spring + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.web.filter.RequestContextFilter + +beans = { + // Reproduce an application filter replacing the thread binding after Grails' request filter. + lateRequestContextFilter(FilterRegistrationBean) { + filter = new RequestContextFilter() + order = -100 + } +} diff --git a/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy new file mode 100644 index 00000000000..10b00978603 --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 issue15819 + +import grails.compiler.GrailsCompileStatic +import org.springframework.web.multipart.MultipartHttpServletRequest + +@GrailsCompileStatic +class SearchController { + + def results() { + render(template: 'results', model: [results: ['Grails', 'Groovy', 'Spring Boot']]) + } + + def upload() { + def file = ((MultipartHttpServletRequest) request).getFile('file') + render(template: 'results', model: [results: [params.title, file.originalFilename, new String(file.bytes, 'UTF-8')]]) + } +} diff --git a/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy new file mode 100644 index 00000000000..aa0d490cae5 --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 issue15819 + +class UrlMappings { + + static mappings = { + "/search/$action"(controller: 'search') + } +} diff --git a/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy b/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy new file mode 100644 index 00000000000..9f2a683ea8e --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 issue15819 + +import groovy.transform.CompileStatic + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +@CompileStatic +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp b/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp new file mode 100644 index 00000000000..2738fec67a6 --- /dev/null +++ b/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp @@ -0,0 +1,23 @@ +<%-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. +--%> + diff --git a/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy b/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy new file mode 100644 index 00000000000..0d718fcb1c6 --- /dev/null +++ b/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 issue15819 + +import spock.lang.Issue +import spock.lang.Specification +import spock.lang.Tag +import spock.lang.Unroll + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport +import org.apache.grails.testing.http.client.MultipartBody + +@Integration +@Tag('http-client') +@Issue('https://github.com/apache/grails-core/issues/15819') +class ResponseRendererFunctionalSpec extends Specification implements HttpClientSupport { + + @Unroll + void 'an upload renders only the template fragment with ajax=#ajax'() { + given: + def multipart = MultipartBody.builder() + .addPart('title', 'Upload title') + .addPart('file', 'results.txt', 'text/plain', 'Uploaded content') + .build() + + when: + def response = httpPostMultipart(ajax ? ['X-Requested-With': 'XMLHttpRequest'] : [:], '/search/upload', multipart) + + then: + response.assertStatus(200) + response.headerValue('Content-Type').startsWith('text/html') + response.body().replaceAll(/\s+/, ' ').trim() == + '' + + where: + ajax << [false, true] + } + + @Unroll + void '#action renders only the _results.gsp template fragment with ajax=#ajax'() { + when: + def response = http(ajax ? ['X-Requested-With': 'XMLHttpRequest'] : [:], "/search/$action") + + then: 'no conventional action view is resolved and no layout or error page is appended' + response.assertStatus(200) + response.headerValue('Content-Type').startsWith('text/html') + response.body().replaceAll(/\s+/, ' ').trim() == + '' + + where: + action | ajax + 'results' | false + 'results' | true + } +} diff --git a/grails-web-mvc/build.gradle b/grails-web-mvc/build.gradle index dfaace63d46..b98c6bc7643 100644 --- a/grails-web-mvc/build.gradle +++ b/grails-web-mvc/build.gradle @@ -42,11 +42,13 @@ dependencies { api 'org.apache.groovy:groovy' compileOnly 'jakarta.servlet:jakarta.servlet-api' + compileOnly 'org.apache.grails.views:grails-gsp-core' compileOnly 'org.springframework:spring-test', { // MockHttpServletRequest/Response/Context used in many classes } testImplementation 'jakarta.servlet:jakarta.servlet-api' + testImplementation 'org.apache.grails.views:grails-gsp-core' testImplementation 'org.springframework:spring-test' testImplementation 'org.apache.groovy:groovy-test-junit5' diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy index 8b099390a35..8c96a5fcea3 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy +++ b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy @@ -34,6 +34,7 @@ import org.springframework.web.servlet.DispatcherServlet import grails.util.Holders import org.grails.web.context.ServletEnvironmentGrailsApplicationDiscoveryStrategy +import org.grails.web.util.GrailsApplicationAttributes import org.grails.web.util.WebUtils /** @@ -54,18 +55,21 @@ class GrailsDispatcherServlet extends DispatcherServlet implements ServletContex @Override protected ServletRequestAttributes buildRequestAttributes(HttpServletRequest request, HttpServletResponse response, RequestAttributes previousAttributes) { - if (previousAttributes == null || !(previousAttributes instanceof GrailsWebRequest)) { - return buildGrailsWebRequest(request, response) - } - else { - GrailsWebRequest webRequest = (GrailsWebRequest) previousAttributes - if (webRequest.isActive()) { - return webRequest - } - else { - return buildGrailsWebRequest(request, response) + if (previousAttributes instanceof GrailsWebRequest) { + GrailsWebRequest previousWebRequest = (GrailsWebRequest) previousAttributes + if (previousWebRequest.isActive()) { + return previousWebRequest } } + GrailsWebRequest webRequest = buildGrailsWebRequest(request, response) + if (previousAttributes != null) { + // A later filter replaced the binding. Publish the dispatch's instance so URL mappings + // and rendering agree, while preserving the request and response wrappers seen here. + // FrameworkServlet passes null on Callable threads, which need their own instance + // without replacing the attribute still used by the dispatch. + request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, webRequest) + } + return webRequest } protected GrailsWebRequest buildGrailsWebRequest(HttpServletRequest request, HttpServletResponse response) { diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy new file mode 100644 index 00000000000..7a953b665b3 --- /dev/null +++ b/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy @@ -0,0 +1,362 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.web.servlet.mvc + +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +import jakarta.servlet.DispatcherType +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponseWrapper + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockMultipartHttpServletRequest +import org.springframework.mock.web.MockServletConfig +import org.springframework.mock.web.MockServletContext +import org.springframework.core.task.SimpleAsyncTaskExecutor +import org.springframework.web.context.request.RequestAttributes +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import org.springframework.web.context.request.async.WebAsyncUtils +import org.springframework.web.context.support.StaticWebApplicationContext +import org.springframework.web.filter.RequestContextFilter +import org.springframework.web.multipart.MultipartResolver +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping +import org.springframework.web.servlet.mvc.Controller + +import org.grails.web.util.GrailsApplicationAttributes +import org.grails.web.util.WebUtils + +import spock.lang.Issue +import spock.lang.Specification + +class GrailsDispatcherServletRequestBindingSpec extends Specification { + + MockServletContext servletContext = new MockServletContext() + + @Issue('https://github.com/apache/grails-core/issues/15819') + void 'a multipart dispatch publishes the request used to read uploaded files after a late filter'() { + given: + def context = new StaticWebApplicationContext() + context.servletContext = servletContext + def request = new MockHttpServletRequest(servletContext, 'POST', '/upload') + request.contentType = 'multipart/form-data; boundary=test' + def response = new MockHttpServletResponse() + def resolvedRequest = new MockMultipartHttpServletRequest(servletContext) + resolvedRequest.method = 'POST' + resolvedRequest.requestURI = '/upload' + resolvedRequest.addParameter('title', 'uploaded title') + resolvedRequest.addFile(new MockMultipartFile('file', 'test.txt', 'text/plain', 'uploaded content'.bytes)) + def resolver = Mock(MultipartResolver) + context.beanFactory.registerSingleton('multipartResolver', resolver) + GrailsWebRequest filterRequest + GrailsWebRequest dispatchRequest + def controller = { req, res -> + dispatchRequest = GrailsWebRequest.lookup() + assert dispatchRequest.is(GrailsWebRequest.lookup(req)) + assert !dispatchRequest.is(filterRequest) + assert dispatchRequest.active + assert dispatchRequest.params.title == 'uploaded title' + def multipartRequest = dispatchRequest.currentRequest + assert multipartRequest.is(resolvedRequest) + assert multipartRequest.getFile('file').originalFilename == 'test.txt' + assert new String(multipartRequest.getFile('file').bytes, 'UTF-8') == 'uploaded content' + null + } as Controller + def mapping = new SimpleUrlHandlerMapping(['/upload': controller], 0) + context.beanFactory.registerSingleton('handlerMapping', mapping) + context.refresh() + mapping.setApplicationContext(context) + def servlet = new GrailsDispatcherServlet(context) + servlet.init(new MockServletConfig(servletContext)) + def filter = new GrailsWebRequestFilter() + filter.setServletContext(servletContext) + filter.setApplicationContext(context) + + when: + filter.doFilter(request, response, { req, res -> + filterRequest = GrailsWebRequest.lookup() + new RequestContextFilter().doFilter(req, res, { lateRequest, lateResponse -> + servlet.service(lateRequest, lateResponse) + } as FilterChain) + } as FilterChain) + + then: + 1 * resolver.isMultipart(request) >> true + 1 * resolver.resolveMultipart(request) >> resolvedRequest + dispatchRequest != null + !dispatchRequest.active + RequestContextHolder.requestAttributes == null + + cleanup: + RequestContextHolder.resetRequestAttributes() + servlet?.destroy() + context.close() + } + + @Issue('https://github.com/apache/grails-core/issues/15819') + void 'service selects an active request and restores the previous binding for #binding with #dispatchType'() { + given: + def context = new StaticWebApplicationContext() + context.servletContext = servletContext + def request = new MockHttpServletRequest(servletContext, 'GET', '/test') + request.dispatcherType = dispatchType + def response = new MockHttpServletResponse() + def existing = new GrailsWebRequest(request, response, servletContext) + existing.params['retained'] = 'value' + if (binding != 'missing' && binding != 'threadOnly') { + request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, existing) + } + if (binding == 'completed' || binding == 'completedAttribute') { + existing.requestCompleted() + } + def previous = binding in ['thread', 'threadOnly', 'completed'] + ? existing + : (binding in ['plain', 'completedAttribute'] ? new ServletRequestAttributes(request, response) : null) + RequestContextHolder.setRequestAttributes(previous) + GrailsWebRequest seen + int completed = 0 + def controller = { req, res -> + seen = GrailsWebRequest.lookup() + if (previous != null && binding != 'threadOnly') { + assert seen.is(GrailsWebRequest.lookup(req)) + } + assert seen.active + seen.registerDestructionCallback('completion', { completed++ } as Runnable, RequestAttributes.SCOPE_REQUEST) + null + } as Controller + def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0) + context.beanFactory.registerSingleton('handlerMapping', mapping) + context.refresh() + mapping.setApplicationContext(context) + def servlet = new GrailsDispatcherServlet(context) + servlet.init(new MockServletConfig(servletContext)) + + when: + servlet.service(request, response) + + then: + seen != null + seen.is(existing) == reuse + !reuse || seen.params.retained == 'value' + !seen.active + completed == 1 + RequestContextHolder.requestAttributes.is(previous) + previous != null || GrailsWebRequest.lookup(request).is(binding == 'attribute' ? existing : null) + + cleanup: + RequestContextHolder.resetRequestAttributes() + servlet?.destroy() + context.close() + + where: + binding | dispatchType | reuse + 'missing' | DispatcherType.REQUEST | false + 'attribute' | DispatcherType.REQUEST | false + 'thread' | DispatcherType.REQUEST | true + 'threadOnly' | DispatcherType.REQUEST | true + 'plain' | DispatcherType.REQUEST | false + 'completed' | DispatcherType.REQUEST | false + 'completed' | DispatcherType.ASYNC | false + 'completed' | DispatcherType.ERROR | false + 'completedAttribute' | DispatcherType.ASYNC | false + 'completedAttribute' | DispatcherType.ERROR | false + } + + @Issue('https://github.com/apache/grails-core/issues/15819') + void 'nested #dispatchType through a late filter restores the enclosing Grails request'() { + given: + def context = new StaticWebApplicationContext() + context.servletContext = servletContext + def request = new MockHttpServletRequest(servletContext, 'GET', '/test') + request.dispatcherType = dispatchType + request.setAttribute(dispatchAttribute, '/test') + def response = new MockHttpServletResponse() + def outer = new GrailsWebRequest(request, response, servletContext) + outer.renderView = false + WebUtils.storeGrailsWebRequest(outer) + GrailsWebRequest inner + def controller = { req, res -> + inner = GrailsWebRequest.lookup() + assert inner.is(GrailsWebRequest.lookup(req)) + assert inner.active + assert !inner.is(outer) + null + } as Controller + def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0) + context.beanFactory.registerSingleton('handlerMapping', mapping) + context.refresh() + mapping.setApplicationContext(context) + def servlet = new GrailsDispatcherServlet(context) + servlet.init(new MockServletConfig(servletContext)) + def filter = new GrailsWebRequestFilter() + filter.setServletContext(servletContext) + filter.setApplicationContext(context) + + when: + filter.doFilter(request, response, { req, res -> + new RequestContextFilter().doFilter(req, res, { nestedRequest, nestedResponse -> + servlet.service(nestedRequest, nestedResponse) + } as FilterChain) + } as FilterChain) + + then: + inner != null + !inner.active + GrailsWebRequest.lookup().is(outer) + GrailsWebRequest.lookup(request).is(outer) + outer.active + !outer.renderView + + cleanup: + RequestContextHolder.resetRequestAttributes() + servlet?.destroy() + context.close() + + where: + dispatchType | dispatchAttribute + DispatcherType.INCLUDE | WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE + DispatcherType.FORWARD | WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE + } + + @Issue('https://github.com/apache/grails-core/issues/15819') + void 'a late filter preserves the request and response wrappers seen by the dispatcher'() { + given: + def context = new StaticWebApplicationContext() + context.servletContext = servletContext + def request = new MockHttpServletRequest(servletContext, 'GET', '/test') + def response = new MockHttpServletResponse() + def wrappedRequest = new HttpServletRequestWrapper(request) { + @Override + boolean isUserInRole(String role) { + role == 'ADMIN' + } + } + def wrappedResponse = new HttpServletResponseWrapper(response) + GrailsWebRequest filterRequest + GrailsWebRequest dispatchRequest + def controller = { req, res -> + dispatchRequest = GrailsWebRequest.lookup() + assert dispatchRequest.request.is(wrappedRequest) + assert dispatchRequest.response.is(wrappedResponse) + assert dispatchRequest.request.isUserInRole('ADMIN') + assert req.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchRequest) + null + } as Controller + def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0) + context.beanFactory.registerSingleton('handlerMapping', mapping) + context.refresh() + mapping.setApplicationContext(context) + def servlet = new GrailsDispatcherServlet(context) + servlet.init(new MockServletConfig(servletContext)) + def filter = new GrailsWebRequestFilter() + filter.setServletContext(servletContext) + filter.setApplicationContext(context) + + when: + filter.doFilter(request, response, { req, res -> + filterRequest = GrailsWebRequest.lookup() + new RequestContextFilter().doFilter(wrappedRequest, wrappedResponse, { lateRequest, lateResponse -> + servlet.service(lateRequest, lateResponse) + } as FilterChain) + } as FilterChain) + + then: + dispatchRequest != null + !dispatchRequest.is(filterRequest) + RequestContextHolder.requestAttributes == null + + cleanup: + RequestContextHolder.resetRequestAttributes() + servlet?.destroy() + context.close() + } + + @Issue('https://github.com/apache/grails-core/issues/15819') + void 'a Callable keeps an active request binding after the initial dispatch returns'() { + given: + def context = new StaticWebApplicationContext() + context.servletContext = servletContext + def started = new CountDownLatch(1) + def proceed = new CountDownLatch(1) + def done = new CountDownLatch(1) + RequestAttributes dispatchAttributes = null + RequestAttributes callableAttributes = null + Object probe = null + Throwable failure = null + def controller = { req, res -> + dispatchAttributes = RequestContextHolder.requestAttributes + def asyncManager = WebAsyncUtils.getAsyncManager(req) + asyncManager.taskExecutor = new SimpleAsyncTaskExecutor() + asyncManager.asyncWebRequest = WebAsyncUtils.createAsyncWebRequest(req, res) + asyncManager.startCallableProcessing({ -> + try { + callableAttributes = RequestContextHolder.requestAttributes + assert req.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchAttributes) + started.countDown() + proceed.await(5, TimeUnit.SECONDS) + probe = RequestContextHolder.currentRequestAttributes().getAttribute('probe', RequestAttributes.SCOPE_REQUEST) + } + catch (Throwable t) { + failure = t + } + finally { + done.countDown() + } + 'ok' + } as Callable) + assert started.await(5, TimeUnit.SECONDS) + null + } as Controller + def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0) + context.beanFactory.registerSingleton('handlerMapping', mapping) + context.refresh() + mapping.setApplicationContext(context) + def servlet = new GrailsDispatcherServlet(context) + servlet.init(new MockServletConfig(servletContext)) + def filter = new GrailsWebRequestFilter() + filter.setServletContext(servletContext) + filter.setApplicationContext(context) + def request = new MockHttpServletRequest(servletContext, 'GET', '/test') + request.asyncSupported = true + request.setAttribute('probe', 'value') + + when: + filter.doFilter(request, new MockHttpServletResponse(), { req, res -> servlet.service(req, res) } as FilterChain) + proceed.countDown() + def finished = done.await(5, TimeUnit.SECONDS) + + then: + finished + failure == null + probe == 'value' + !callableAttributes.is(dispatchAttributes) + + cleanup: + proceed.countDown() + RequestContextHolder.resetRequestAttributes() + servlet?.destroy() + context.close() + } + +} diff --git a/settings.gradle b/settings.gradle index 83bd87bb709..270891ecddc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -472,6 +472,7 @@ include( 'grails-test-examples-gsp-layout', 'grails-test-examples-gsp-sitemesh3', 'grails-test-examples-gsp-spring-boot', + 'grails-test-examples-issue-15819', 'grails-test-examples-issue-698-domain-save-npe', 'grails-test-examples-hyphenated', 'grails-test-examples-issue-views-182', @@ -528,6 +529,7 @@ project(':grails-test-examples-plugins-loadafter').projectDir = file('grails-tes project(':grails-test-examples-plugins-issue11005').projectDir = file('grails-test-examples/plugins/issue11005') project(':grails-test-examples-issue-11767').projectDir = file('grails-test-examples/issue-11767') project(':grails-test-examples-issue-15228').projectDir = file('grails-test-examples/issue-15228') +project(':grails-test-examples-issue-15819').projectDir = file('grails-test-examples/issue-15819') project(':grails-test-examples-issue-10279').projectDir = file('grails-test-examples/issue-10279') project(':grails-test-examples-plugins-exploded').projectDir = file('grails-test-examples/plugins/exploded') project(':grails-test-examples-plugins-issue-11767').projectDir = file('grails-test-examples/plugins/issue-11767')