diff --git a/gradle/thymeleaf-verification.init.gradle b/gradle/thymeleaf-verification.init.gradle new file mode 100644 index 0000000..eeab08c --- /dev/null +++ b/gradle/thymeleaf-verification.init.gradle @@ -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}" + } + } +} diff --git a/gradle/verify-thymeleaf-artifacts.py b/gradle/verify-thymeleaf-artifacts.py new file mode 100644 index 0000000..380784d --- /dev/null +++ b/gradle/verify-thymeleaf-artifacts.py @@ -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() diff --git a/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java new file mode 100644 index 0000000..c62dc3e --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java @@ -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 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", "PORTAL", "2026.09.07").doesNotContain(">default"); + }); + } + + @Test + void mvcFormKeepsBindingSelectionErrorsMessagesAndRequestDataProcessing() { + runner.run(context -> { + Form form = new Form(); + BindingResult errors = new BeanPropertyBindingResult(form, "form"); + errors.rejectValue("email", "email.invalid"); + Map 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").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").doesNotContain("로그인 안내")); + } + + private String render(WebApplicationContext context, String template, Map 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 fieldTypes = new ArrayList<>(); + final List 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 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; } + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafExpressionCompatibilityTest.java b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafExpressionCompatibilityTest.java new file mode 100644 index 0000000..581e03d --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafExpressionCompatibilityTest.java @@ -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 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( + "

", context())).isEqualTo("

portal

")), + dynamicTest(name + "method", () -> assertThat(engine.process( + "

", context())).isEqualTo("

PORTAL

")), + dynamicTest(name + "collection and condition", () -> assertThat(engine.process( + "

1}\" th:text=\"${items[1]}\">

", context())) + .isEqualTo("

b

")), + dynamicTest(name + "date", () -> assertThat(engine.process( + "

", context())) + .isEqualTo("

2026.09.07

")), + dynamicTest(name + "JavaScript inline", () -> assertThat(engine.process( + "", context())) + .isEqualTo(""))); + }); + } + + 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", "PORTAL", "

2026.09.07

") + .doesNotContain(">default", "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) + "", + "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 + ""); + } else { + assertThat(html).doesNotContain(">" + number + ""); + } + } + 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", "테스트 환경 안내"); + } + } + } + } + } +} diff --git a/src/test/resources/templates/compatibility/form.html b/src/test/resources/templates/compatibility/form.html new file mode 100644 index 0000000..52c599c --- /dev/null +++ b/src/test/resources/templates/compatibility/form.html @@ -0,0 +1,16 @@ + + + +

+

+
+ +

+

+ +
+ + diff --git a/src/test/resources/templates/compatibility/layout.html b/src/test/resources/templates/compatibility/layout.html new file mode 100644 index 0000000..33094c3 --- /dev/null +++ b/src/test/resources/templates/compatibility/layout.html @@ -0,0 +1,10 @@ + + + +Portal + + +
layout-shell
+
default
+ + diff --git a/src/test/resources/templates/compatibility/page.html b/src/test/resources/templates/compatibility/page.html new file mode 100644 index 0000000..54b734e --- /dev/null +++ b/src/test/resources/templates/compatibility/page.html @@ -0,0 +1,11 @@ + + + +
+ + +

+

+
+ + diff --git a/src/test/resources/templates/compatibility/security.html b/src/test/resources/templates/compatibility/security.html new file mode 100644 index 0000000..2e4fa31 --- /dev/null +++ b/src/test/resources/templates/compatibility/security.html @@ -0,0 +1,9 @@ + + + +

로그인 안내

+

회원 메뉴

+

관리자 메뉴

+ + +