diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 008524b..cbf3b4c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -66,6 +66,7 @@ jobs: **/target/surefire-reports/ **/target/failsafe-reports/ **/target/jsp-engine/ + jakarta-test/target/packaged-war.log esapi-compatibility: name: ESAPI ${{ matrix.esapi-version }} diff --git a/jakarta-test/README.md b/jakarta-test/README.md new file mode 100644 index 0000000..4fb3317 --- /dev/null +++ b/jakarta-test/README.md @@ -0,0 +1,88 @@ +# Required browser and packaged WAR fixture + +This optional application is a test fixture, not a dependency of an encoder +library. From the repository root, with JDK 17, Maven and Docker available: + +```sh +mvn -B -ntp -Dmaven.repo.local=/tmp/encoder-browser-m2 clean verify -PtestJakarta +``` + +Use an empty task-specific Maven directory for fresh validation. The reactor +packages the matching encoder JARs without installing them. CI requires this +profile in the `Java CI gate`; unavailable Docker is a failure, not a skipped or +advisory test. CI also compares the JAR inside the WAR byte-for-byte with the +reactor's Jakarta adapter. + +## Coverage decision (#93) + +Retain the browser fixture. The Docker-free [Jasper tests](../compatibility/jsp-engine/README.md) +cover every packaged basic/advanced tag and EL binding, coercions, output bytes, +and invalid JSP translation. They cannot replace these browser assertions: + +- `ItemControllerTest`: JSP/JSTL startup, tag and EL output interpreted as text in + actual DOM cells, no injected script elements, standards-mode HTML. +- `JavaScriptTemplateTest`: all four JavaScript encoders through quoted strings + and ordinary template literals, interpolation boundaries, HTML script and + event-attribute parsing, controls and lone surrogates through UTF-8, and the + explicitly unsupported raw-template round-trip behavior. +- `PackagedWarIT`: launches `java -jar` on the finished executable WAR on a + random loopback port, renders both packaged views, checks exact encoded cell + content, and confirms JSTL API/implementation and adapter JARs are packaged. + It terminates the server even on failure. This test needs no Docker. + +Browser sessions and containers are explicitly closed in `AfterAll` with +`finally` cleanup. Video recording is disabled, so no unused recorder image is +started. Surefire/Failsafe output and `target/packaged-war.log` are retained by CI. +A local Chrome-only diagnostic for the JavaScript suite remains available with +`-Dencoder.browser.local=true -Dtest=JavaScriptTemplateTest`; it is not the CI gate. + +## Framework and API boundaries + +As reviewed on 2026-09-25, this fixture uses supported Spring Boot **4.1.1** and +its managed dependencies, on JDK **17**, with Tomcat/Jasper **11.0.26** +(Servlet **6.1**, Pages **4.0**, EL **6.0**). The two deliberate BOM overrides are +Tomcat 11.0.26, which contains the September fixes absent from Boot's managed +11.0.24, and Selenium 4.49.0, aligned with the current reviewed browser image. +Testcontainers **2.0.5**, JSTL API **3.0.2** and implementation **3.0.1** follow the +Boot BOM. The standalone Servlet/Pages/EL API JARs are removed; Tomcat supplies +the coherent implementation/API set. Both JSTL components remain packaged. +The unused JSON starter, empty test configuration/launcher, and unused service +mutation scaffold are removed. + +See the [Boot support policy](https://github.com/spring-projects/spring-boot/wiki/Supported-Versions), +[system requirements](https://docs.spring.io/spring-boot/system-requirements.html), +[Spring advisories](https://spring.io/security/), and +[Tomcat 11 advisories](https://tomcat.apache.org/security-11.html). +An OSV query of the 34 resolved third-party JAR coordinates in the packaged WAR +(including provided container libraries) returned no advisories on 2026-09-25. +That dated result excludes container OS packages, build plugins and test-only +JARs; it is not a permanent or repository-wide clean bill. +Recheck these sources and the resolved dependency graph with each upgrade and +before release. Do not copy these fixture requirements into library support claims. +The published adapters retain Java 8 and their existing provided APIs. The +separate javax/Jakarta engines and Java 8/11/17/21/25 packaged consumers still +exercise older contracts. OSGi's conservative Pages import range is unchanged; +this Boot application is not an OSGi container test. + +## Container provenance and updates + +`BrowserFixture.java` and test-only `testcontainers.properties` contain immutable +multi-platform index digests fetched from Docker Hub's registry and verified +against the SHA-256 of each manifest response on 2026-09-25: + +| Use | Reviewed tag | Index SHA-256 | +| --- | --- | --- | +| Browser | `selenium/standalone-chrome:4.49.0-20260909` | `7efe71e7e4a83bdf574b26bd354690928075e8f443223d2ced16a2c208eae1d7` | +| Cleanup | `testcontainers/ryuk:0.14.0` | `7c1a8a9a47c780ed0f983770a662f80deb115d95cce3e2daa3d12115b8cd28f0` | +| Host-port forwarding | `testcontainers/sshd:1.3.0` | `c50c0f59554dcdb2d9e5e705112144428ae9d04ac0af6322b365a18e24213a6a` | +| Docker startup probe | `alpine:3.24.2` | `294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` | + +The [Selenium release](https://github.com/SeleniumHQ/docker-selenium/releases/tag/4.49.0-20260909) +and [Testcontainers 2.0.5 source](https://github.com/testcontainers/testcontainers-java/tree/2.0.5) +control the browser/helper choices. The startup probe uses maintained Alpine +instead of the old default 3.17. Digest pins provide immutable identity, not a +claim that an image contains no vulnerabilities. Review publisher release notes, +image scan results and all helper versions when updating. Keep the Selenium +client and image aligned, update the tag and digest together, verify the manifest +hash/platforms again, and run the full required profile before merging. These +source/property pins require manual review; Maven Dependabot does not update them. diff --git a/jakarta-test/pom.xml b/jakarta-test/pom.xml index 37d4111..a64daf7 100644 --- a/jakarta-test/pom.xml +++ b/jakarta-test/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.16 + 4.1.1 org.owasp.encoder.testing @@ -16,6 +16,10 @@ Test for OWASP encoder jakarta JSP 17 + + 11.0.26 + + 4.49.0 1.5.0-SNAPSHOT @@ -28,7 +32,14 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-jackson + + org.apache.tomcat.embed @@ -40,25 +51,14 @@ spring-boot-starter-tomcat provided - - jakarta.servlet - jakarta.servlet-api - provided - - - jakarta.servlet.jsp - jakarta.servlet.jsp-api - 3.1.0 - provided - jakarta.servlet.jsp.jstl jakarta.servlet.jsp.jstl-api - - - jakarta.el - jakarta.el-api - 5.0.1 + + + jakarta.eljakarta.el-api + jakarta.servletjakarta.servlet-api + org.glassfish.web @@ -70,14 +70,9 @@ spring-boot-starter-test test - - org.springframework.boot - spring-boot-testcontainers - test - org.testcontainers - selenium + testcontainers-selenium test @@ -90,16 +85,25 @@ selenium-chrome-driver test - - org.testcontainers - junit-jupiter - test - jakarta-test + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${project.build.directory}/${project.build.finalName}.war + + + + + integration-testverify + + + org.springframework.boot spring-boot-maven-plugin diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java index 3b22a6f..7824cde 100644 --- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java +++ b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/controller/ItemController.java @@ -1,6 +1,7 @@ package org.owasp.encoder.testing.jakarta_test.controller; -import org.owasp.encoder.testing.jakarta_test.service.ItemService; +import java.util.List; +import org.owasp.encoder.testing.jakarta_test.dto.Item; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -14,15 +15,13 @@ @RequestMapping("/item") public class ItemController { - private final ItemService itemService; - - public ItemController(ItemService itemService) { - this.itemService = itemService; - } + private static final List ITEMS = List.of( + new Item(1, "menu", "blob"), + new Item(2, "top", "fancy ")); @GetMapping("/viewItems") public String viewItems(Model model) { - model.addAttribute("items", itemService.getItems()); + model.addAttribute("items", ITEMS); return "view-items"; } } diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java index 4cda55c..0c1c72b 100644 --- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java +++ b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/dto/Item.java @@ -1,19 +1,10 @@ package org.owasp.encoder.testing.jakarta_test.dto; -/** - * - * @author jeremy - */ -public class Item { - - private int id; - - private String name; - - private String description; - - public Item() { - } +/** Immutable values exposed as bean properties to JSP EL. */ +public final class Item { + private final int id; + private final String name; + private final String description; public Item(int id, String name, String description) { this.id = id; @@ -21,57 +12,7 @@ public Item(int id, String name, String description) { this.description = description; } - /** - * Get the value of id - * - * @return the value of id - */ - public int getId() { - return id; - } - - /** - * Set the value of id - * - * @param id new value of id - */ - public void setId(int id) { - this.id = id; - } - - /** - * Get the value of name - * - * @return the value of name - */ - public String getName() { - return name; - } - - /** - * Set the value of name - * - * @param name new value of name - */ - public void setName(String name) { - this.name = name; - } - - /** - * Get the value of description - * - * @return the value of description - */ - public String getDescription() { - return description; - } - - /** - * Set the value of description - * - * @param description new value of description - */ - public void setDescription(String description) { - this.description = description; - } + public int getId() { return id; } + public String getName() { return name; } + public String getDescription() { return description; } } diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java deleted file mode 100644 index fe2a45f..0000000 --- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/ItemService.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.owasp.encoder.testing.jakarta_test.service; - -import java.util.Collection; -import org.owasp.encoder.testing.jakarta_test.dto.Item; - -/** - * - * @author jeremy - */ -public interface ItemService { - Collection getItems(); - - Item addItem(Item item); -} diff --git a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java b/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java deleted file mode 100644 index 4807594..0000000 --- a/jakarta-test/src/main/java/org/owasp/encoder/testing/jakarta_test/service/impl/ItemServiceImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.owasp.encoder.testing.jakarta_test.service.impl; - -import java.util.ArrayList; -import java.util.Collection; -import org.owasp.encoder.testing.jakarta_test.dto.Item; -import org.owasp.encoder.testing.jakarta_test.service.ItemService; -import org.springframework.stereotype.Service; - -/** - * - * @author jeremy - */ -@Service -public class ItemServiceImpl implements ItemService { - - @Override - public Collection getItems() { - Collection items = new ArrayList<>(); - items.add(new Item(1, "menu", "blob")); - items.add(new Item(2, "top", "fancy ")); - return items; - } - - @Override - public Item addItem(Item item) { - throw new UnsupportedOperationException("Not supported yet."); // Generated from nbfs://nbhost/SystemFileSystem/Templates/Classes/Code/GeneratedMethodBody - } - -} diff --git a/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp b/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp index 7abf69b..305aed6 100644 --- a/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp +++ b/jakarta-test/src/main/webapp/WEB-INF/jsp/index.jsp @@ -1,4 +1,5 @@ -<%@page contentType="text/html" pageEncoding="UTF-8"%> +<%@page session="false" contentType="text/html" pageEncoding="UTF-8"%> +<%@taglib prefix="c" uri="jakarta.tags.core"%> @@ -7,6 +8,6 @@

Hello World!

- You are likely looking for the test page located here. + You are likely looking for the test page located ">here. diff --git a/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp b/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp index 69e2488..769092a 100644 --- a/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp +++ b/jakarta-test/src/main/webapp/WEB-INF/jsp/view-items.jsp @@ -1,6 +1,7 @@ -<%@page contentType="text/html;charset=UTF-8" language="java"%> +<%@page session="false" contentType="text/html;charset=UTF-8" language="java"%> <%@taglib prefix="c" uri="jakarta.tags.core"%> <%@taglib prefix="e" uri="owasp.encoder.jakarta"%> + View Items diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java new file mode 100644 index 0000000..ba702e6 --- /dev/null +++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/BrowserFixture.java @@ -0,0 +1,21 @@ +package org.owasp.encoder.testing.jakarta_test; + +import org.openqa.selenium.chrome.ChromeOptions; +import org.testcontainers.containers.BrowserWebDriverContainer; +import org.testcontainers.utility.DockerImageName; + +/** One reviewed image for every required browser test; see README.md. */ +final class BrowserFixture { + private static final String IMAGE = "selenium/standalone-chrome:4.49.0-20260909@sha256:7efe71e7e4a83bdf574b26bd354690928075e8f443223d2ced16a2c208eae1d7"; + + static BrowserWebDriverContainer container(ChromeOptions options) { + return new BrowserWebDriverContainer<>(DockerImageName.parse(IMAGE) + // Testcontainers 2 parses tag+digest names differently during its compatibility check. + .asCompatibleSubstituteFor("selenium/standalone-chrome")) + .withCapabilities(options) + .withSharedMemorySize(2L * 1024 * 1024 * 1024) + .withRecordingMode(BrowserWebDriverContainer.VncRecordingMode.SKIP, null); + } + + private BrowserFixture() { } +} diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java index c08cbb4..19df57d 100644 --- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java +++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/ItemControllerTest.java @@ -1,12 +1,11 @@ package org.owasp.encoder.testing.jakarta_test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; -import org.openqa.selenium.NoSuchElementException; -import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.remote.RemoteWebDriver; import org.springframework.beans.factory.annotation.Autowired; @@ -15,51 +14,42 @@ import org.springframework.core.env.Environment; import org.testcontainers.Testcontainers; import org.testcontainers.containers.BrowserWebDriverContainer; -import org.testcontainers.junit.jupiter.Container; -/** - * - * @author jeremy - */ +/** Actual browser interpretation of server-rendered tag and EL output. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class ItemControllerTest { - - @Container - static BrowserWebDriverContainer container = new BrowserWebDriverContainer<>(). - withCapabilities(new ChromeOptions()); +class ItemControllerTest { + private static BrowserWebDriverContainer container; + private static RemoteWebDriver browser; @LocalServerPort private int port; @BeforeAll - static void beforeAll(@Autowired Environment environment) { - Testcontainers.exposeHostPorts(environment.getProperty("local.server.port", Integer.class)); + static void start(@Autowired Environment environment) { + Testcontainers.exposeHostPorts(environment.getRequiredProperty("local.server.port", Integer.class)); + ChromeOptions options = new ChromeOptions().addArguments("--headless=new"); + container = BrowserFixture.container(options); container.start(); + browser = new RemoteWebDriver(container.getSeleniumAddress(), options); } - @Test - void shouldDisplayMessage() { - RemoteWebDriver browser = new RemoteWebDriver(container.getSeleniumAddress(), new ChromeOptions()); - browser.get("http://host.testcontainers.internal:" + port + "/jakarta-test/item/viewItems"); - WebElement first = browser.findElement(By.id("b2")); - WebElement second = browser.findElement(By.id("c2")); - assertEquals("top", first.getText()); - assertEquals("fancy ", second.getText()); - //todo yes - there are much better ways to check for an exception in junit - NoSuchElementException exception = null; + @AfterAll + static void stop() { try { - first.findElement(By.tagName("script")); - } catch (NoSuchElementException ex) { - exception = ex; + if (browser != null) browser.quit(); + } finally { + if (container != null) container.stop(); } - assertNotNull(exception); + } - exception = null; - try { - second.findElement(By.tagName("script")); - } catch (NoSuchElementException ex) { - exception = ex; - } - assertNotNull(exception); + @Test + void rendersTagAndFunctionAsTextWithoutCreatingScripts() { + browser.get("http://host.testcontainers.internal:" + port + "/jakarta-test/item/viewItems"); + assertEquals("View Items", browser.getTitle()); + assertEquals(2, browser.findElements(By.cssSelector("tbody tr")).size()); + assertEquals("top", browser.findElement(By.id("b2")).getText()); + assertEquals("fancy ", browser.findElement(By.id("c2")).getText()); + assertTrue(browser.findElements(By.tagName("script")).isEmpty()); + assertEquals("CSS1Compat", browser.executeScript("return document.compatMode")); } } diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java deleted file mode 100644 index 55a46fd..0000000 --- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JakartaTestApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.owasp.encoder.testing.jakarta_test; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; - -@Import(TestcontainersConfiguration.class) -@SpringBootTest -class JakartaTestApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java index f190066..1cb1956 100644 --- a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java +++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/JavaScriptTemplateTest.java @@ -41,7 +41,7 @@ static void startBrowser() { if (Boolean.getBoolean("encoder.browser.local")) { browser = new ChromeDriver(options); } else { - container = new BrowserWebDriverContainer<>().withCapabilities(options); + container = BrowserFixture.container(options); container.start(); browser = new RemoteWebDriver(container.getSeleniumAddress(), options); } @@ -60,11 +60,10 @@ void inputCannotCompleteInterpolationAfterTrustedDollar() { @AfterAll static void stopBrowser() { - if (browser != null) { - browser.quit(); - } - if (container != null) { - container.stop(); + try { + if (browser != null) browser.quit(); + } finally { + if (container != null) container.stop(); } } diff --git a/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java new file mode 100644 index 0000000..806a8ac --- /dev/null +++ b/jakarta-test/src/test/java/org/owasp/encoder/testing/jakarta_test/PackagedWarIT.java @@ -0,0 +1,80 @@ +package org.owasp.encoder.testing.jakarta_test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.owasp.encoder.Encode; +import static org.junit.jupiter.api.Assertions.*; + +/** Starts the executable WAR itself, including its packaged JSP/JSTL dependencies. */ +class PackagedWarIT { + @Test + @Timeout(90) + void executableWarStartsAndRendersPackagedViews() throws Exception { + Path war = Path.of(System.getProperty("fixture.war")).toAbsolutePath(); + try (ZipFile zip = new ZipFile(war.toFile())) { + for (String prefix : new String[]{"WEB-INF/lib/jakarta.servlet.jsp.jstl-api-", + "WEB-INF/lib/jakarta.servlet.jsp.jstl-", "WEB-INF/lib/encoder-jakarta-jsp-"}) { + assertTrue(zip.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), prefix); + } + assertNotNull(zip.getEntry("WEB-INF/jsp/view-items.jsp")); + for (String prefix : new String[]{"WEB-INF/lib/jakarta.el-api-", "WEB-INF/lib/jakarta.servlet-api-", + "WEB-INF/lib/jakarta.servlet.jsp-api-"}) { + assertTrue(zip.stream().noneMatch(entry -> entry.getName().startsWith(prefix)), prefix); + } + } + Path log = war.getParent().resolve("packaged-war.log"); + Process server = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-jar", war.toString(), "--server.address=127.0.0.1", "--server.port=0") + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + var portPattern = Pattern.compile("Tomcat started on port (\\d+)"); + int port = 0; + long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos(); + while (System.nanoTime() < deadline && server.isAlive()) { + var matcher = portPattern.matcher(Files.readString(log)); + if (matcher.find()) { port = Integer.parseInt(matcher.group(1)); break; } + Thread.sleep(100); + } + assertTrue(port > 0, () -> "WAR failed to start; inspect " + log); + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + String root = "http://127.0.0.1:" + port + "/jakarta-test"; + HttpResponse index = get(client, root + "/"); + assertEquals(200, index.statusCode()); + assertTrue(index.body().contains("href=\"/jakarta-test/item/viewItems\""), index.body()); + HttpResponse page = get(client, root + "/item/viewItems"); + assertEquals(200, page.statusCode(), page.body()); + assertEquals(Encode.forHtml("top"), cell(page.body(), "b2")); + assertEquals(Encode.forHtml("fancy "), cell(page.body(), "c2")); + assertFalse(page.body().contains("