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
+21
View File
@@ -0,0 +1,21 @@
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
// Read-only dependency resolution; does not override versions or the normal build.
gradle.projectsEvaluated {
def portal = gradle.rootProject
portal.tasks.register('thymeleafCompatibilityDependencies') {
doLast {
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
output.mkdirs()
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
def rows = artifacts.findAll {
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
}.collect {
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
}.sort()
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
}
println "Dependency evidence: ${output}"
}
}
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
import argparse
import hashlib
import io
import json
from pathlib import Path
import re
import zipfile
EXPECTED_CHANGES = {
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"ognl:ognl": ("3.3.4", "3.3.5"),
}
EXPECTED_CLASSES = {
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
"ognl/Ognl.class": "ognl-3.3.5.jar",
}
LOCAL_PREFIXES = (
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
)
def require(condition, message):
if not condition:
raise ValueError(message)
def dependencies(path):
result = {}
for line in path.read_text().splitlines():
module, version, filename = line.split("\t")
entries = result.setdefault(module, [])
require((version, filename) not in entries, "Duplicate artifact: " + filename)
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
entries.append((version, filename))
for entries in result.values():
entries.sort()
return result
def compare(before, after):
old, new = dependencies(before), dependencies(after)
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
return {"external_modules": len(new), "changes": changed}
def inspect_war(path):
owners = {name: [] for name in EXPECTED_CLASSES}
with zipfile.ZipFile(path) as war:
names = war.namelist()
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
jars = sorted(name for name in names if name.endswith(".jar"))
basenames = [Path(name).name for name in jars]
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
for name in names), "Local profile packaged: " + str(path))
for name in jars:
basename = Path(name).name
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
classes = set(jar.namelist())
require(not any(c.startswith("jakarta/servlet/") for c in classes),
"Jakarta Servlet classes packaged: " + name)
for target in owners:
if target in classes:
owners[target].append(name)
# Java 8 compatibility of each upgraded library's entry class.
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
"Java >8 class: " + name + "!" + target)
for target, expected in EXPECTED_CLASSES.items():
require(owners[target] == ["WEB-INF/lib/" + expected],
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
for pattern, expected in [
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
(r"ognl-", "ognl-3.3.5.jar"),
]:
require([n for n in basenames if re.match(pattern, n)] == [expected],
"Wrong/duplicate library version for " + expected)
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"jar_count": len(jars), "class_providers": owners, "jars": jars}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
parser.add_argument("--war", type=Path, action="append", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
result = {
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
"wars": [inspect_war(path) for path in args.war],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
print("PASS: dependency changes limited to three modules; both WARs verified")
for war in result["wars"]:
print(war["file"], war["sha256"])
if __name__ == "__main__":
main()
@@ -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>