Thymeleaf 호환성 테스트 추가:
- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현 - 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
This commit is contained in:
+207
@@ -0,0 +1,207 @@
|
||||
package com.eactive.apim.portal.common.compatibility;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.support.RequestDataValueProcessor;
|
||||
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
import org.thymeleaf.spring5.view.ThymeleafView;
|
||||
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ThymeleafBootMvcCompatibilityTest {
|
||||
|
||||
// No user-defined engine/resolver: Boot must create and initialize both.
|
||||
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, ThymeleafAutoConfiguration.class))
|
||||
.withUserConfiguration(SupportConfiguration.class)
|
||||
.withPropertyValues("spring.thymeleaf.prefix=classpath:/templates/",
|
||||
"spring.thymeleaf.suffix=.html", "spring.thymeleaf.encoding=UTF-8", "spring.thymeleaf.cache=false");
|
||||
|
||||
@AfterEach
|
||||
void clearThreadContexts() {
|
||||
SecurityContextHolder.clearContext();
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bootAutoConfigurationInitializesAndRendersLayout() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(SpringTemplateEngine.class)
|
||||
.hasSingleBean(ThymeleafViewResolver.class);
|
||||
SpringTemplateEngine engine = context.getBean(SpringTemplateEngine.class);
|
||||
assertThat(engine.getConfiguration()).isNotNull();
|
||||
assertThat(engine.getDialects()).anyMatch(LayoutDialect.class::isInstance)
|
||||
.anyMatch(SpringSecurityDialect.class::isInstance);
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("name", "portal");
|
||||
model.put("date", java.time.LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||
assertThat(render(context, "compatibility/page", model))
|
||||
.contains("layout-shell", "<b>PORTAL</b>", "2026.09.07").doesNotContain(">default</main>");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mvcFormKeepsBindingSelectionErrorsMessagesAndRequestDataProcessing() {
|
||||
runner.run(context -> {
|
||||
Form form = new Form();
|
||||
BindingResult errors = new BeanPropertyBindingResult(form, "form");
|
||||
errors.rejectValue("email", "email.invalid");
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("form", form);
|
||||
model.put(BindingResult.MODEL_KEY_PREFIX + "form", errors);
|
||||
String html = render(context, "compatibility/form", model);
|
||||
assertThat(html).contains("가입 정보", "이메일 확인 필요", "name=\"email\"", "value=\"user@example.com\"",
|
||||
"action=\"/portal/submit\"", "name=\"role\"", "value=\"USER\" selected=\"selected\"",
|
||||
"name=\"_csrf\"", "value=\"compat-token\"")
|
||||
.doesNotContain("value=\"ADMIN\" selected=\"selected\"", "th:field", "th:errors");
|
||||
RecordingProcessor processor = context.getBean(RecordingProcessor.class);
|
||||
assertThat(processor.fieldTypes).containsExactly("email", "option", "option");
|
||||
assertThat(processor.actions).containsExactly("POST /portal/submit");
|
||||
|
||||
// A different selection and a valid form must clear the error and old selection.
|
||||
form.setRole("ADMIN");
|
||||
model.put(BindingResult.MODEL_KEY_PREFIX + "form", new BeanPropertyBindingResult(form, "form"));
|
||||
assertThat(render(context, "compatibility/form", model))
|
||||
.contains("value=\"ADMIN\" selected=\"selected\"")
|
||||
.doesNotContain("이메일 확인 필요", "value=\"USER\" selected=\"selected\"");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousUserSeesOnlyAnonymousContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken(
|
||||
"compatibility", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("로그인 안내").doesNotContain("회원 메뉴", "관리자 메뉴", "id=\"identity\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedUserSeesIdentityAndUserContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
"portal-user", "unused", AuthorityUtils.createAuthorityList("ROLE_USER")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("회원 메뉴", ">portal-user</span>").doesNotContain("로그인 안내", "관리자 메뉴"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void administratorSeesRoleProtectedContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
"portal-admin", "unused", AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("회원 메뉴", "관리자 메뉴", ">portal-admin</span>").doesNotContain("로그인 안내"));
|
||||
}
|
||||
|
||||
private String render(WebApplicationContext context, String template, Map<String, Object> model) throws Exception {
|
||||
context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());
|
||||
request.setContextPath("/portal");
|
||||
request.setRequestURI("/portal/signup");
|
||||
request.setMethod("GET");
|
||||
request.setPreferredLocales(Collections.singletonList(Locale.KOREA));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
|
||||
try {
|
||||
ThymeleafViewResolver resolver = context.getBean(ThymeleafViewResolver.class);
|
||||
assertThat(resolver.resolveViewName(template, Locale.KOREA)).isInstanceOf(ThymeleafView.class);
|
||||
resolver.resolveViewName(template, Locale.KOREA).render(model, request, response);
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8");
|
||||
return response.getContentAsString();
|
||||
} finally {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class SupportConfiguration {
|
||||
@Bean
|
||||
LayoutDialect layoutDialect() {
|
||||
return new LayoutDialect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
StaticMessageSource messageSource() {
|
||||
StaticMessageSource messages = new StaticMessageSource();
|
||||
messages.addMessage("form.title", Locale.KOREA, "가입 정보");
|
||||
messages.addMessage("email.invalid", Locale.KOREA, "이메일 확인 필요");
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Bean
|
||||
RecordingProcessor requestDataValueProcessor() {
|
||||
return new RecordingProcessor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
DefaultWebSecurityExpressionHandler webSecurityExpressionHandler() {
|
||||
return new DefaultWebSecurityExpressionHandler();
|
||||
}
|
||||
}
|
||||
|
||||
static class RecordingProcessor implements RequestDataValueProcessor {
|
||||
final List<String> fieldTypes = new ArrayList<>();
|
||||
final List<String> actions = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String processAction(HttpServletRequest request, String action, String method) {
|
||||
actions.add(method.toUpperCase(Locale.ROOT) + " " + action);
|
||||
return action;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
|
||||
fieldTypes.add(type);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
|
||||
return Collections.singletonMap("_csrf", "compat-token");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String processUrl(HttpServletRequest request, String url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Form {
|
||||
private String email = "user@example.com";
|
||||
private String role = "USER";
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.eactive.apim.portal.common.compatibility;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/** The original 15 probe scenarios, using only classpath fixtures and production fragments. */
|
||||
class ThymeleafExpressionCompatibilityTest {
|
||||
|
||||
static Context context() {
|
||||
Context context = new Context(Locale.KOREA);
|
||||
context.setVariable("name", "portal");
|
||||
context.setVariable("items", Arrays.asList("a", "b"));
|
||||
context.setVariable("date", LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||
return context;
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> expressionCompatibility() {
|
||||
return Stream.of(false, true).flatMap(spring -> {
|
||||
TemplateEngine engine = spring ? new SpringTemplateEngine() : new TemplateEngine();
|
||||
engine.setTemplateResolver(new StringTemplateResolver());
|
||||
String name = spring ? "SpringEL " : "OGNL ";
|
||||
return Stream.of(
|
||||
dynamicTest(name + "initialization", () -> assertThat(engine.getConfiguration()).isNotNull()),
|
||||
dynamicTest(name + "property", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${name}\"></p>", context())).isEqualTo("<p>portal</p>")),
|
||||
dynamicTest(name + "method", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${name.toUpperCase()}\"></p>", context())).isEqualTo("<p>PORTAL</p>")),
|
||||
dynamicTest(name + "collection and condition", () -> assertThat(engine.process(
|
||||
"<p th:if=\"${#lists.size(items) > 1}\" th:text=\"${items[1]}\"></p>", context()))
|
||||
.isEqualTo("<p>b</p>")),
|
||||
dynamicTest(name + "date", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${#temporals.format(date, 'yyyy.MM.dd')}\"></p>", context()))
|
||||
.isEqualTo("<p>2026.09.07</p>")),
|
||||
dynamicTest(name + "JavaScript inline", () -> assertThat(engine.process(
|
||||
"<script th:inline=\"javascript\">var name = [[${name}]];</script>", context()))
|
||||
.isEqualTo("<script>var name = \"portal\";</script>")));
|
||||
});
|
||||
}
|
||||
|
||||
private SpringTemplateEngine resourceEngine() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setSuffix(".html");
|
||||
resolver.setCharacterEncoding("UTF-8");
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutCompositionWithSecurityDialect() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
engine.addDialect(new LayoutDialect());
|
||||
engine.addDialect(new SpringSecurityDialect());
|
||||
assertThat(engine.process("compatibility/page", context()))
|
||||
.contains("layout-shell", "<b>PORTAL</b>", "<p>2026.09.07</p>")
|
||||
.doesNotContain(">default</main>", "layout:decorate", "th:text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualPaginationKeepsNumbersEventsAndBoundaryStates() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
Context context = context();
|
||||
context.setVariable("jsFunction", "loadPage");
|
||||
for (int pageNumber : new int[]{0, 2, 4}) {
|
||||
context.setVariable("page", new PageImpl<>(Arrays.asList("a", "b"),
|
||||
PageRequest.of(pageNumber, 10), 50));
|
||||
String html = engine.process("views/fragment/pagination", Collections.singleton("pagination"), context);
|
||||
assertThat(html).contains("page-current\">" + (pageNumber + 1) + "</a>",
|
||||
"onclick=\"loadPage(1, 10);\"", "onclick=\"loadPage(5, 10);\"",
|
||||
"onclick=\"loadPage(" + Math.max(1, pageNumber) + ", 10);\"",
|
||||
"onclick=\"loadPage(" + Math.min(5, pageNumber + 2) + ", 10);\"");
|
||||
for (int number = 1; number <= 5; number++) {
|
||||
if (Math.abs(number - (pageNumber + 1)) <= 2) {
|
||||
assertThat(html).contains(">" + number + "</a>");
|
||||
} else {
|
||||
assertThat(html).doesNotContain(">" + number + "</a>");
|
||||
}
|
||||
}
|
||||
assertThat(html.contains("class=\"page-first disabled\"")).isEqualTo(pageNumber == 0);
|
||||
assertThat(html.contains("class=\"page-prev disabled\"")).isEqualTo(pageNumber == 0);
|
||||
assertThat(html.contains("class=\"page-next disabled\"")).isEqualTo(pageNumber == 4);
|
||||
assertThat(html.contains("class=\"page-last disabled\"")).isEqualTo(pageNumber == 4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualSmsAndEmailNoticeVisibility() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
Context context = context();
|
||||
for (String fragment : Arrays.asList("smsNotice", "emailNotice")) {
|
||||
for (boolean show : new boolean[]{true, false}) {
|
||||
for (String code : new String[]{"123456", null}) {
|
||||
context.setVariable("showTestAuthNotice", show);
|
||||
context.setVariable("testAuthNumber", code);
|
||||
String html = engine.process("views/fragment/test-env-auth-notice",
|
||||
Collections.singleton(fragment), context);
|
||||
if (show && code != null) {
|
||||
assertThat(html).contains("123456", "테스트 환경 안내", fragment.equals("smsNotice") ? "SMS" : "이메일");
|
||||
} else {
|
||||
assertThat(html).doesNotContain("123456", "test-env-notice", "테스트 환경 안내");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<h1 th:text="#{form.title}">
|
||||
</h1>
|
||||
<form th:action="@{/submit}" th:object="${form}" method="post">
|
||||
<input type="email" th:field="*{email}">
|
||||
<p th:errors="*{email}">
|
||||
</p>
|
||||
<select th:field="*{role}">
|
||||
<option value="USER">사용자</option>
|
||||
<option value="ADMIN">관리자</option>
|
||||
</select>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
<head>
|
||||
<title>Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>layout-shell</header>
|
||||
<main layout:fragment="content">default</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{compatibility/layout}">
|
||||
<body>
|
||||
<main layout:fragment="content">
|
||||
<b th:text="${name.toUpperCase()}">
|
||||
</b>
|
||||
<p th:text="${#temporals.format(date, 'yyyy.MM.dd')}">
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||
<body>
|
||||
<p sec:authorize="isAnonymous()">로그인 안내</p>
|
||||
<p sec:authorize="isAuthenticated()">회원 메뉴</p>
|
||||
<p sec:authorize="hasRole('ADMIN')">관리자 메뉴</p>
|
||||
<span id="identity" sec:authorize="isAuthenticated()" sec:authentication="name"></span>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user