You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/10/13 21:35:21 UTC
(grails-core) branch test/web-util-coverage-7.0.x created (now b8752338ef)
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a change to branch test/web-util-coverage-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
at b8752338ef Add unit tests for web request helpers and XML writers.
This branch includes the following new commits:
new b8752338ef Add unit tests for web request helpers and XML writers.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(grails-core) 01/01: Add unit tests for web request helpers and XML writers.
Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch test/web-util-coverage-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit b8752338ef83b5cbeac8b2315577f28814441f17
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Tue Oct 13 17:34:06 2026 -0400
Add unit tests for web request helpers and XML writers.
Cover the encoded-byte counter, include wrapper, included content, and the pretty and streaming XML writers, and extend WebUtils coverage.
Assisted-by: sisyphus-grok:grok-4.7
---
.../BoundedCharsAsEncodedBytesCounterSpec.groovy | 161 +++++++++++++
.../web/util/IncludeResponseWrapperSpec.groovy | 181 +++++++++++++++
.../org/grails/web/util/IncludedContentSpec.groovy | 88 ++++++++
.../groovy/org/grails/web/util/WebUtilsSpec.groovy | 249 ++++++++++++++++++++-
.../web/xml/PrettyPrintXMLStreamWriterSpec.groovy | 112 +++++++++
.../web/xml/StreamingMarkupWriterSpec.groovy | 76 +++++++
6 files changed, 855 insertions(+), 12 deletions(-)
diff --git a/grails-web-common/src/test/groovy/org/grails/web/util/BoundedCharsAsEncodedBytesCounterSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/util/BoundedCharsAsEncodedBytesCounterSpec.groovy
new file mode 100644
index 0000000000..4862be3194
--- /dev/null
+++ b/grails-web-common/src/test/groovy/org/grails/web/util/BoundedCharsAsEncodedBytesCounterSpec.groovy
@@ -0,0 +1,161 @@
+/*
+ * 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.util
+
+import java.nio.charset.UnsupportedCharsetException
+
+import spock.lang.Specification
+
+class BoundedCharsAsEncodedBytesCounterSpec extends Specification {
+
+ def "counts encoded bytes through string and character updates"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(32, encoding)
+ counter.getCountingWriter()
+
+ when:
+ counter.update('é')
+ counter.update('xöx'.toCharArray(), 1, 1)
+ counter.update('a'.toCharArray())
+ counter.update('')
+ counter.update(new char[0])
+
+ then:
+ counter.size() == expected
+
+ where:
+ encoding | expected
+ 'UTF-8' | 5
+ 'ISO-8859-1' | 3
+ }
+
+ def "writer is initialized lazily and reused with configured capacity and encoding"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter()
+ counter.setCapacity(16)
+ counter.setEncoding('UTF-8')
+
+ expect:
+ counter.capacity == 16
+ counter.encoding == 'UTF-8'
+ !counter.writerReferenced
+
+ when:
+ def writer = counter.getCountingWriter()
+ writer.write('xéx'.toCharArray(), 1, 1)
+ writer.write(65)
+ writer.write('xbx'.toCharArray(), 1, 1)
+ writer.append(new StringBuilder('more'))
+ writer.flush()
+ writer.close()
+
+ then:
+ counter.writerReferenced
+ counter.getCountingWriter().is(writer)
+ counter.size() == 8
+ }
+
+ def "appends character sequence slices including sequences larger than the write buffer"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(20000, 'UTF-8')
+ def writer = counter.getCountingWriter()
+
+ expect:
+ writer.append(sequence, 1, sequence.length() - 1).is(writer)
+ counter.size() == expected
+
+ where:
+ sequence | expected
+ new StringBuilder('xéx') | 2
+ new StringBuffer('xéx') | 2
+ new StringBuilder('x' * 9002) | 9000
+ new StringBuffer('x' * 9002) | 9000
+ }
+
+ def "exact capacity is counted but overflow permanently stops calculation"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(2, 'UTF-8')
+ def writer = counter.getCountingWriter()
+
+ when:
+ writer.write('é'.toCharArray())
+
+ then:
+ counter.size() == 2
+
+ when:
+ writer.write('a'.toCharArray())
+ writer.write(65)
+ writer.append(new StringBuilder('ignored'))
+ writer.append('ignored', 0, 7)
+ counter.update('ignored')
+ counter.update('ignored'.toCharArray())
+
+ then:
+ counter.size() == -1
+ }
+
+ def "a multibyte character cannot fit into the remaining capacity"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(1, 'UTF-8')
+ def writer = counter.getCountingWriter()
+
+ when:
+ writer.write('é'.toCharArray())
+
+ then:
+ counter.size() == -1
+ }
+
+ def "unmappable and malformed characters are replaced when counting"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(8, 'US-ASCII')
+ def writer = counter.getCountingWriter()
+
+ when:
+ writer.write('é\uD800'.toCharArray())
+
+ then:
+ counter.size() == 2
+ }
+
+ def "invalid character range stops calculation"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(8, 'UTF-8')
+ counter.getCountingWriter()
+
+ when:
+ counter.update('abc'.toCharArray(), 3, 1)
+
+ then:
+ counter.size() == -1
+ }
+
+ def "unsupported encoding fails when obtaining the writer"() {
+ given:
+ def counter = new BoundedCharsAsEncodedBytesCounter(8, 'not-a-charset')
+
+ when:
+ counter.getCountingWriter()
+
+ then:
+ thrown(UnsupportedCharsetException)
+ !counter.writerReferenced
+ }
+}
diff --git a/grails-web-common/src/test/groovy/org/grails/web/util/IncludeResponseWrapperSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/util/IncludeResponseWrapperSpec.groovy
new file mode 100644
index 0000000000..b579208300
--- /dev/null
+++ b/grails-web-common/src/test/groovy/org/grails/web/util/IncludeResponseWrapperSpec.groovy
@@ -0,0 +1,181 @@
+/*
+ * 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.util
+
+import jakarta.servlet.WriteListener
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class IncludeResponseWrapperSpec extends Specification {
+
+ def "captures writer output without modifying the original response"() {
+ given:
+ def response = new MockHttpServletResponse()
+ def wrapper = new IncludeResponseWrapper(response)
+
+ expect:
+ wrapper.getContent() == ''
+
+ when:
+ def writer = wrapper.getWriter()
+ writer.write('included é')
+ writer.flush()
+ wrapper.flushBuffer()
+
+ then:
+ wrapper.getWriter().is(writer)
+ wrapper.getContent().toString() == 'included é'
+ response.contentAsString == ''
+ !wrapper.committed
+
+ when:
+ wrapper.getOutputStream()
+
+ then:
+ def error = thrown(IllegalStateException)
+ error.message == 'Method getWriter() already called'
+ }
+
+ def "captures all output stream write overloads and decodes UTF-8"() {
+ given:
+ def response = new MockHttpServletResponse()
+ def wrapper = new IncludeResponseWrapper(response)
+
+ when:
+ def stream = wrapper.getOutputStream()
+ stream.write('é'.getBytes('UTF-8'))
+ stream.write('xabx'.getBytes('UTF-8'), 1, 2)
+ stream.write(33)
+ stream.setWriteListener(Stub(WriteListener))
+
+ then:
+ stream.ready
+ wrapper.getOutputStream().is(stream)
+ wrapper.getContent() == 'éab!'
+ response.contentAsByteArray.length == 0
+
+ when:
+ wrapper.getWriter()
+
+ then:
+ def error = thrown(IllegalStateException)
+ error.message == 'Method getOutputStream() already called'
+ }
+
+ def "uses an explicit encoding when reading stream content"() {
+ given:
+ def wrapper = new IncludeResponseWrapper(new MockHttpServletResponse())
+ wrapper.getOutputStream().write('café'.getBytes('ISO-8859-1'))
+
+ expect:
+ wrapper.getContent('ISO-8859-1') == 'café'
+ }
+
+ def "reset discards writer content while retaining the writer"() {
+ given:
+ def wrapper = new IncludeResponseWrapper(new MockHttpServletResponse())
+ def output = wrapper.getWriter()
+ output.write('old')
+
+ when:
+ wrapper.resetBuffer()
+
+ then:
+ wrapper.getContent().toString() == ''
+
+ when:
+ output.write(65)
+
+ then:
+ wrapper.getContent().toString() == 'A'
+
+ when:
+ wrapper.reset()
+
+ then:
+ wrapper.getContent().toString() == ''
+
+ wrapper.getWriter().is(output)
+ }
+
+ def "include metadata and errors do not change the original response"() {
+ given:
+ def response = new MockHttpServletResponse()
+ response.setLocale(Locale.US)
+ def wrapper = new IncludeResponseWrapper(response)
+
+ when:
+ wrapper.setContentType('text/plain')
+ wrapper.setStatus(201)
+ wrapper.setLocale(Locale.FRANCE)
+ wrapper.setContentLength(100)
+
+ then:
+ wrapper.contentType == 'text/plain'
+ wrapper.status == 201
+ response.contentType == null
+ response.status == 200
+ response.locale == Locale.US
+ response.getHeader('Content-Length') == null
+
+ when:
+ wrapper.sendError(404, 'missing')
+
+ then:
+ wrapper.status == 404
+ !wrapper.committed
+ response.status == 200
+
+ when:
+ wrapper.sendError(500)
+
+ then:
+ wrapper.status == 500
+ !wrapper.committed
+ }
+
+ def "redirect commits the wrapper and forbids resets and errors"() {
+ given:
+ def response = new MockHttpServletResponse()
+ def wrapper = new IncludeResponseWrapper(response)
+
+ when:
+ wrapper.sendRedirect('/login')
+
+ then:
+ wrapper.committed
+ wrapper.redirectURL == '/login'
+ response.redirectedUrl == '/login'
+
+ when:
+ operation.call(wrapper)
+
+ then:
+ def error = thrown(IllegalStateException)
+ error.message == 'Response already committed'
+
+ where:
+ operation << [
+ { IncludeResponseWrapper included -> included.resetBuffer() },
+ { IncludeResponseWrapper included -> included.reset() },
+ { IncludeResponseWrapper included -> included.sendError(500) },
+ { IncludeResponseWrapper included -> included.sendError(500, 'failed') }
+ ]
+ }
+}
diff --git a/grails-web-common/src/test/groovy/org/grails/web/util/IncludedContentSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/util/IncludedContentSpec.groovy
new file mode 100644
index 0000000000..c11b493d27
--- /dev/null
+++ b/grails-web-common/src/test/groovy/org/grails/web/util/IncludedContentSpec.groovy
@@ -0,0 +1,88 @@
+/*
+ * 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.util
+
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class IncludedContentSpec extends Specification {
+
+ def "writes and exposes string object and absent content"() {
+ given:
+ def included = new IncludedContent('text/plain', content)
+ def target = new StringWriter()
+
+ expect:
+ included.contentType == 'text/plain'
+ included.content == content
+ included.redirectURL == null
+ included.writeTo(target).is(target)
+ target.toString() == expected
+ included.contentAsCharArray == expected.toCharArray()
+
+ where:
+ content | expected
+ 'café' | 'café'
+ 42 | '42'
+ null | ''
+ }
+
+ def "writes buffered include content and supplies the default content type"() {
+ given:
+ def wrapper = new IncludeResponseWrapper(new MockHttpServletResponse())
+ wrapper.getWriter().write('buffered é')
+ def included = new IncludedContent(null, wrapper.getContent())
+ def target = new StringWriter()
+
+ expect:
+ included.contentType == 'text/html;charset=UTF-8'
+ included.writeTo(target).is(target)
+ target.toString() == 'buffered é'
+ included.contentAsCharArray == 'buffered é'.toCharArray()
+ }
+
+ def "redirect content has no body"() {
+ given:
+ def included = new IncludedContent('/login')
+ def target = new StringWriter()
+ target.write('existing')
+
+ expect:
+ included.redirectURL == '/login'
+ included.content == null
+ included.contentType == 'text/html;charset=UTF-8'
+ included.contentAsCharArray.length == 0
+ included.writeTo(target).is(target)
+ target.toString() == 'existing'
+ }
+
+ def "propagates errors from the destination writer"() {
+ given:
+ def included = new IncludedContent('text/plain', 'body')
+ def target = Mock(Writer)
+
+ when:
+ included.writeTo(target)
+
+ then:
+ 1 * target.write('body') >> { throw new IOException('destination failed') }
+ def error = thrown(IOException)
+ error.message == 'destination failed'
+ }
+}
diff --git a/grails-web-common/src/test/groovy/org/grails/web/util/WebUtilsSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/util/WebUtilsSpec.groovy
index 28f717268c..d25743611f 100644
--- a/grails-web-common/src/test/groovy/org/grails/web/util/WebUtilsSpec.groovy
+++ b/grails-web-common/src/test/groovy/org/grails/web/util/WebUtilsSpec.groovy
@@ -18,6 +18,13 @@
*/
package org.grails.web.util
+import org.grails.web.servlet.mvc.GrailsWebRequest
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.web.context.request.RequestContextHolder
+import org.springframework.web.context.request.ServletRequestAttributes
+import org.springframework.web.servlet.View
+import org.springframework.web.servlet.ViewResolver
import spock.lang.Issue
import spock.lang.Specification
@@ -26,18 +33,236 @@ import spock.lang.Specification
*/
class WebUtilsSpec extends Specification {
- @Issue("https://github.com/apache/grails-core/issues/10545")
- def testToQueryString() {
- given:
- Map params = ["name":"sudhir-nimavat", "address.zip":"12345"]
+ @Issue("https://github.com/apache/grails-core/issues/10545")
+ def testToQueryString() {
+ given:
+ Map params = ["name":"sudhir-nimavat", "address.zip":"12345"]
- when:
- String result = WebUtils.toQueryString(params)
+ when:
+ String result = WebUtils.toQueryString(params)
- then:
- result.startsWith("?")
- def tokens = result[1..-1].split('&')
- tokens.find({ it == "name=sudhir-nimavat"}) != null
- tokens.find({ it == "address.zip=12345"}) != null
- }
+ then:
+ result.startsWith("?")
+ def tokens = result[1..-1].split('&')
+ tokens.find({ it == "name=sudhir-nimavat"}) != null
+ tokens.find({ it == "address.zip=12345"}) != null
+ }
+
+ def "decodes query strings and collects repeated parameters in order"() {
+ expect:
+ WebUtils.fromQueryString('?name=caf%C3%A9&tag=one&tag=two&tag=three&empty=&ignored&first+name=A+B') == [
+ name: 'café', tag: ['one', 'two', 'three'], empty: '', 'first name': 'A B'
+ ]
+ WebUtils.fromQueryString('') == [:]
+ WebUtils.fromQueryString('name=value') == [name: 'value']
+ }
+
+ def "rejects malformed percent escapes in query strings"() {
+ when:
+ WebUtils.fromQueryString('name=%ZZ')
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ def "adds controller prefixes only to relative view names"() {
+ expect:
+ WebUtils.addViewPrefix(viewName, controllerName) == expected
+
+ where:
+ viewName | controllerName | expected
+ 'show' | 'book' | '/book/show'
+ 'show' | null | '/show'
+ '/shared/show' | 'book' | '/shared/show'
+ 'redirect:/login' | 'book' | 'redirect:/login'
+ 'forward:/internal' | 'book' | 'forward:/internal'
+ }
+
+ def "resolves a controller view using the current default locale without a web request"() {
+ given:
+ def request = new MockHttpServletRequest()
+ def resolver = Mock(ViewResolver)
+ def view = Stub(View)
+
+ when:
+ def resolved = WebUtils.resolveView(request, 'show', 'book', resolver)
+
+ then:
+ 1 * resolver.resolveViewName('/book/show', Locale.getDefault()) >> view
+ resolved.is(view)
+ }
+
+ def "strips the application context and Grails dispatch path from request URIs"() {
+ given:
+ def request = new MockHttpServletRequest()
+ request.contextPath = '/app'
+ request.requestURI = uri
+
+ expect:
+ WebUtils.getRequestURIForGrailsDispatchURI(request) == expected
+
+ where:
+ uri | expected
+ '/app/grails/book/show.dispatch' | '/book/show'
+ '/app/book/show.dispatch' | '/book/show'
+ '/app/book/show' | '/book/show'
+ }
+
+ def "exposes include paths without overwriting existing attributes and restores old values"() {
+ given:
+ def request = new MockHttpServletRequest()
+ request.requestURI = '/app/book/show'
+ request.contextPath = '/app'
+ request.servletPath = '/book'
+ request.pathInfo = '/show'
+ request.queryString = 'id=1'
+ request.setAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE, '/original')
+ def attributes = [
+ (WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE): '/included',
+ (WebUtils.INCLUDE_QUERY_STRING_ATTRIBUTE): 'id=2'
+ ]
+
+ when:
+ WebUtils.exposeIncludeRequestAttributes(request)
+
+ then:
+ request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE) == '/original'
+ request.getAttribute(WebUtils.INCLUDE_CONTEXT_PATH_ATTRIBUTE) == '/app'
+ request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE) == '/book'
+ request.getAttribute(WebUtils.INCLUDE_PATH_INFO_ATTRIBUTE) == '/show'
+ request.getAttribute(WebUtils.INCLUDE_QUERY_STRING_ATTRIBUTE) == 'id=1'
+
+ when:
+ def oldValues = WebUtils.exposeRequestAttributesAndReturnOldValues(request, attributes)
+
+ then:
+ oldValues == [
+ (WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE): '/original',
+ (WebUtils.INCLUDE_QUERY_STRING_ATTRIBUTE): 'id=1'
+ ]
+ request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE) == '/included'
+ request.getAttribute(WebUtils.INCLUDE_QUERY_STRING_ATTRIBUTE) == 'id=2'
+
+ when:
+ WebUtils.cleanupIncludeRequestAttributes(request, oldValues)
+
+ then:
+ request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE) == '/original'
+ request.getAttribute(WebUtils.INCLUDE_QUERY_STRING_ATTRIBUTE) == 'id=1'
+ request.getAttribute(WebUtils.INCLUDE_CONTEXT_PATH_ATTRIBUTE) == null
+ request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE) == null
+ request.getAttribute(WebUtils.INCLUDE_PATH_INFO_ATTRIBUTE) == null
+ }
+
+ def "attribute exposure returns only values that existed before the include"() {
+ given:
+ def request = new MockHttpServletRequest()
+ request.setAttribute('existing', 'old')
+
+ expect:
+ WebUtils.exposeRequestAttributesAndReturnOldValues(request, [existing: 'new', added: 'value']) == [existing: 'old']
+ request.getAttribute('existing') == 'new'
+ request.getAttribute('added') == 'value'
+ }
+
+ def "attribute exposure requires a request and an attributes map"() {
+ when:
+ WebUtils.exposeRequestAttributesAndReturnOldValues(request, attributes)
+
+ then:
+ thrown(IllegalArgumentException)
+
+ where:
+ request | attributes
+ null | [:]
+ new MockHttpServletRequest() | null
+ }
+
+ def "detects dispatch types and prefers a nonblank forward URI"() {
+ given:
+ def request = new MockHttpServletRequest()
+ request.requestURI = '/current'
+
+ expect:
+ !WebUtils.isForward(request)
+ !WebUtils.isInclude(request)
+ !WebUtils.isForwardOrInclude(request)
+ !WebUtils.isAsync(request)
+ !WebUtils.isError(request)
+ WebUtils.getForwardURI(request) == '/current'
+
+ when:
+ request.setAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE, '/original')
+ request.setAttribute(WebUtils.ASYNC_REQUEST_URI_ATTRIBUTE, '/async')
+ request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, 500)
+
+ then:
+ WebUtils.isForward(request)
+ WebUtils.isForwardOrInclude(request)
+ WebUtils.isAsync(request)
+ WebUtils.isError(request)
+ WebUtils.getForwardURI(request) == '/original'
+
+ when:
+ request.setAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE, ' ')
+
+ then:
+ WebUtils.getForwardURI(request) == '/current'
+
+ when:
+ request.removeAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE)
+ request.setAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE, '/included')
+
+ then:
+ !WebUtils.isForward(request)
+ WebUtils.isInclude(request)
+ WebUtils.isForwardOrInclude(request)
+ }
+
+ def "stores retrieves and clears the web request in both request contexts"() {
+ given:
+ def previous = RequestContextHolder.getRequestAttributes()
+ def request = new MockHttpServletRequest()
+ def webRequest = new GrailsWebRequest(request, new MockHttpServletResponse(), request.servletContext)
+
+ when:
+ WebUtils.storeGrailsWebRequest(webRequest)
+
+ then:
+ WebUtils.retrieveGrailsWebRequest().is(webRequest)
+ GrailsWebRequest.lookup(request).is(webRequest)
+
+ when:
+ WebUtils.clearGrailsWebRequest()
+ WebUtils.clearGrailsWebRequest()
+
+ then:
+ RequestContextHolder.getRequestAttributes() == null
+ GrailsWebRequest.lookup(request) == null
+ !request.attributeNames.hasMoreElements()
+
+ cleanup:
+ RequestContextHolder.setRequestAttributes(previous)
+ }
+
+ def "retrieving a web request fails without request attributes and returns null for a non-Grails request"() {
+ given:
+ def previous = RequestContextHolder.getRequestAttributes()
+ RequestContextHolder.resetRequestAttributes()
+
+ when:
+ WebUtils.retrieveGrailsWebRequest()
+
+ then:
+ thrown(IllegalStateException)
+
+ when:
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest()))
+
+ then:
+ WebUtils.retrieveGrailsWebRequest() == null
+
+ cleanup:
+ RequestContextHolder.setRequestAttributes(previous)
+ }
}
diff --git a/grails-web-common/src/test/groovy/org/grails/web/xml/PrettyPrintXMLStreamWriterSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/xml/PrettyPrintXMLStreamWriterSpec.groovy
new file mode 100644
index 0000000000..01749083ed
--- /dev/null
+++ b/grails-web-common/src/test/groovy/org/grails/web/xml/PrettyPrintXMLStreamWriterSpec.groovy
@@ -0,0 +1,112 @@
+/*
+ * 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.xml
+
+import spock.lang.Specification
+
+class PrettyPrintXMLStreamWriterSpec extends Specification {
+
+ def "pretty prints a document with nested elements attributes escaped text and empty elements"() {
+ given:
+ def target = new StringWriter()
+ def writer = new PrettyPrintXMLStreamWriter(new StreamingMarkupWriter(target, 'UTF-8'))
+ def newline = PrettyPrintXMLStreamWriter.NEWLINE
+
+ when:
+ writer.startDocument('UTF-8', '1.0')
+ .startNode('root').attribute('id', 'a"&b')
+ .startNode('child').characters('a < b & c > d').end()
+ .startNode('empty').end()
+ .end()
+
+ then:
+ target.toString() == [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ '<root id="a"&b">',
+ ' <child>',
+ ' a < b & c > d',
+ ' </child>',
+ ' <empty />',
+ '</root>',
+ ''
+ ].join(newline)
+ }
+
+ def "uses custom indentation at each nested level"() {
+ given:
+ def target = new StringWriter()
+ def writer = new PrettyPrintXMLStreamWriter(new StreamingMarkupWriter(target), '....')
+
+ when:
+ writer.startNode('outer').startNode('middle').startNode('inner').end().end().end()
+
+ then:
+ target.toString() == [
+ '<outer>',
+ '....<middle>',
+ '........<inner />',
+ '....</middle>',
+ '</outer>',
+ ''
+ ].join(PrettyPrintXMLStreamWriter.NEWLINE)
+ }
+
+ def "rejects attributes outside an open start tag"() {
+ given:
+ def writer = new PrettyPrintXMLStreamWriter(new StreamingMarkupWriter(new StringWriter()))
+ if (afterText) {
+ writer.startNode('root').characters('body')
+ }
+
+ when:
+ writer.attribute('id', 'invalid')
+
+ then:
+ thrown(IllegalStateException)
+
+ where:
+ afterText << [false, true]
+ }
+
+ def "rejects a document declaration after an element has started"() {
+ given:
+ def writer = new PrettyPrintXMLStreamWriter(new StreamingMarkupWriter(new StringWriter()))
+ writer.startNode('root')
+
+ when:
+ writer.startDocument('UTF-8', '1.0')
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ def "propagates output failures"() {
+ given:
+ def target = Mock(Writer)
+ def writer = new PrettyPrintXMLStreamWriter(new StreamingMarkupWriter(target))
+
+ when:
+ writer.startNode('root')
+
+ then:
+ 1 * target.write(60) >> { throw new IOException('output failed') }
+ def error = thrown(IOException)
+ error.message == 'output failed'
+ }
+}
diff --git a/grails-web-common/src/test/groovy/org/grails/web/xml/StreamingMarkupWriterSpec.groovy b/grails-web-common/src/test/groovy/org/grails/web/xml/StreamingMarkupWriterSpec.groovy
new file mode 100644
index 0000000000..fa08092ac6
--- /dev/null
+++ b/grails-web-common/src/test/groovy/org/grails/web/xml/StreamingMarkupWriterSpec.groovy
@@ -0,0 +1,76 @@
+/*
+ * 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.xml
+
+import spock.lang.Specification
+
+class StreamingMarkupWriterSpec extends Specification {
+
+ def "escapes double quotes only in attributes while preserving XML escaping"() {
+ given:
+ def target = new StringWriter()
+ def writer = new StreamingMarkupWriter(target)
+ writer.setWritingAttribute(attribute)
+
+ when:
+ writer.escaped().write('"<&>')
+
+ then:
+ target.toString() == expected
+
+ where:
+ attribute | expected
+ true | '"<&>'
+ false | '"<&>'
+ }
+
+ def "switches from attributes to text and permits unescaped markup"() {
+ given:
+ def target = new StringWriter()
+ def writer = new StreamingMarkupWriter(target, 'UTF-8')
+
+ when:
+ writer.unescaped().write('<node title="')
+ writer.setWritingAttribute(true)
+ writer.write(34)
+ writer.setWritingAttribute(false)
+ writer.unescaped().write('">')
+ writer.escaped().write('"café"')
+ writer.unescaped().write('</node>')
+ writer.flush()
+
+ then:
+ target.toString() == '<node title=""">"café"</node>'
+ }
+
+ def "propagates failures when escaping an attribute quote"() {
+ given:
+ def target = Mock(Writer)
+ def writer = new StreamingMarkupWriter(target)
+ writer.setWritingAttribute(true)
+
+ when:
+ writer.write(34)
+
+ then:
+ 1 * target.write('"') >> { throw new IOException('output failed') }
+ def error = thrown(IOException)
+ error.message == 'output failed'
+ }
+}