Thymeleaf 호환성 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현
- 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
This commit is contained in:
Rinjae
2026-09-07 15:29:45 +09:00
parent b7869ad6e6
commit fa736a36ab
8 changed files with 514 additions and 0 deletions
@@ -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; }
}
}
@@ -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>