This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/struts.git
commit 1a4471e25a9a0ea18e9e8d7fa4907a4daee42a17 Author: Lukasz Lenart <[email protected]> AuthorDate: Sun Oct 25 17:12:32 2026 +0100 WW-5811 fix(html5): never let derived date constraints reject what the server accepts - The property type is read on the form's submission action class. A different rendering action could declare the same name with another type; a ModelDriven submission action it does not render gives null, as its model type is only known at runtime. - DATE ranges dispatch through addRange again, so a 7.4.0 subclass overriding it keeps receiving them. - A java.util.Date bound before the Gregorian cutover is skipped: DateConverter reads earlier yyyy-MM-dd values as Julian dates, so 1500-01-01 rendered as min="1500-01-10". - A field with its own step gets no derived min, which would become the step base and move the developer's grid. Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../java/org/apache/struts2/components/Form.java | 28 ++++++++------ .../components/StrutsHtmlConstraintProvider.java | 15 +++++--- .../java/org/apache/struts2/components/UIBean.java | 4 ++ .../apache/struts2/TestConfigurationProvider.java | 3 +- .../components/ConstraintAttributesTest.java | 44 ++++++++++++++++++++++ .../StrutsHtmlConstraintProviderTest.java | 27 +++++++++++++ 6 files changed, 104 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/components/Form.java b/core/src/main/java/org/apache/struts2/components/Form.java index 8e5ccdbab..ba9a84d0e 100644 --- a/core/src/main/java/org/apache/struts2/components/Form.java +++ b/core/src/main/java/org/apache/struts2/components/Form.java @@ -439,17 +439,18 @@ public class Form extends ClosingUIBean { } /** - * The declared type of the property a field binds to, found on the object parameter binding would - * write to and then walked through declared types rather than values, so a null intermediate object - * (which binding creates) does not hide it. Null for an indexed or unknown path. + * The declared type of the property a field binds to on the form's submission action, walked + * through declared types rather than values, so a null intermediate object (which binding creates) + * does not hide it. Null for an indexed or unknown path, and for a model-driven submission action + * the form is not rendered by, whose model type is only known at runtime. */ Class<?> getPropertyType(String name) { if (name == null || name.isEmpty() || StringUtils.containsAny(name, '[', '(')) { return null; } String[] segments = name.split("\\."); - for (Object root : bindingRoots()) { - Class<?> type = declaredType(root.getClass(), segments[0]); + for (Class<?> root : bindingRoots()) { + Class<?> type = declaredType(root, segments[0]); if (type != null) { for (int i = 1; i < segments.length && type != null; i++) { type = declaredType(type, segments[i]); @@ -460,17 +461,22 @@ public class Form extends ClosingUIBean { return null; } - private List<Object> bindingRoots() { + private List<Class<?>> bindingRoots() { + Class<?> actionClass = (Class<?>) getAttributes().get(ATTR_ACTION_CLASS); + if (actionClass == null) { + return Collections.emptyList(); + } Object invoked = currentAction(); - if (invoked == null) { - return new ArrayList<>(getStack().getRoot()); + if (!actionClass.isInstance(invoked)) { + return ModelDriven.class.isAssignableFrom(actionClass) + ? Collections.emptyList() : Collections.singletonList(actionClass); } - List<Object> roots = new ArrayList<>(2); + List<Class<?>> roots = new ArrayList<>(2); Object model = modelPushedAbove(invoked, getStack().getRoot()); if (model != null) { - roots.add(model); + roots.add(model.getClass()); } - roots.add(invoked); + roots.add(invoked.getClass()); return roots; } diff --git a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java index f53839f5c..b64fbd5ca 100644 --- a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java +++ b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java @@ -37,6 +37,7 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -137,11 +138,7 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { } else if (validator instanceof DoubleRangeFieldValidator doubleValidator) { addDoubleRange(attributes, doubleValidator, control); } else if (validator instanceof RangeValidatorSupport<?> rangeValidator) { - if (control == HtmlControlType.DATE) { - addDateRange(attributes, rangeValidator); - } else { - addRange(attributes, rangeValidator, control); - } + addRange(attributes, rangeValidator, control); } } @@ -224,6 +221,10 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { if (bound instanceof LocalDate localDate) { date = localDate; } else if (bound instanceof Date utilDate) { + // before the cutover DateConverter reads yyyy-MM-dd as a Julian date, the browser as a Gregorian one + if (utilDate.before(new GregorianCalendar().getGregorianChange())) { + return null; + } date = Instant.ofEpochMilli(utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(); } else { return null; @@ -272,6 +273,10 @@ public class StrutsHtmlConstraintProvider implements HtmlConstraintProvider { } protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, HtmlControlType control) { + if (control == HtmlControlType.DATE) { + addDateRange(attributes, validator); + return; + } if (!isNumericRange(control)) { return; } diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java b/core/src/main/java/org/apache/struts2/components/UIBean.java index 353fd6abd..06d7f3f29 100644 --- a/core/src/main/java/org/apache/struts2/components/UIBean.java +++ b/core/src/main/java/org/apache/struts2/components/UIBean.java @@ -996,6 +996,10 @@ public abstract class UIBean extends Component { // duplicate attribute, of which the browser keeps the first constraints.keySet().removeIf(attributeName -> "type".equalsIgnoreCase(attributeName) || isAlreadyRendered(attributeName)); + // min is the step base, so it would move the developer's own step grid + if (isAlreadyRendered("step")) { + constraints.remove("min"); + } if (!constraints.isEmpty()) { addParameter("constraints", constraints); } diff --git a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java index 22f15f1c4..085dc354f 100644 --- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java +++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java @@ -39,6 +39,7 @@ import org.apache.struts2.interceptor.TokenSessionStoreInterceptor; import org.apache.struts2.interceptor.parameter.ParametersInterceptor; import org.apache.struts2.result.ServletDispatcherResult; import org.apache.struts2.components.ConstraintAction; +import org.apache.struts2.components.ModelDrivenConstraintAction; import org.apache.struts2.interceptor.ModelDrivenInterceptor; import org.apache.struts2.views.jsp.ui.DoubleValidationAction; @@ -103,7 +104,7 @@ public class TestConfigurationProvider implements ConfigurationProvider { .addInterceptor(new InterceptorMapping("validation", validationInterceptor)) .build(); - ActionConfig modelDrivenConstraintActionConfig = new ActionConfig.Builder("", "modelDrivenConstraintAction", ConstraintAction.class.getName()) + ActionConfig modelDrivenConstraintActionConfig = new ActionConfig.Builder("", "modelDrivenConstraintAction", ModelDrivenConstraintAction.class.getName()) .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName()) .addParam("location", "success.jsp") .build()) diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java index b87c3a9a6..99a0b9690 100644 --- a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java @@ -136,6 +136,21 @@ public class ConstraintAttributesTest extends AbstractUITagTest { constraints == null || !constraints.containsKey("maxlength")); } + /** + * A {@code min} becomes the step base, so on a field with {@code step="7"} it would move the + * developer's grid and block values the server accepts. + */ + public void testADeveloperStepSuppressesTheDerivedMinButKeepsTheMax() throws Exception { + initDispatcherWith("true"); + fieldName = "startDate"; + dynamicAttributes.put("STEP", "7"); + + Map<String, String> constraints = renderFieldAndReturnConstraints("date"); + + assertFalse(constraints.containsKey("min")); + assertEquals("2026-12-31", constraints.get("max")); + } + /** * {@code html5/text.ftl} has already emitted a hardcoded {@code type} by the time the constraint * map renders, so a {@code type} from a provider would be a duplicate attribute the browser drops. @@ -445,6 +460,22 @@ public class ConstraintAttributesTest extends AbstractUITagTest { assertEquals(java.time.LocalDate.class, propertyTypeHandedToTheProvider()); } + public void testThePropertyTypeIsReadFromTheSubmissionActionWhenAnotherActionRendersTheForm() throws Exception { + actionOverride = new RenderingAction(); + initDispatcherWith("true"); + fieldName = "birthday"; + + assertEquals(java.util.Date.class, propertyTypeHandedToTheProvider()); + } + + public void testAModelDrivenSubmissionActionRenderedByAnotherActionHandsTheProviderNoType() throws Exception { + actionName = "modelDrivenConstraintAction"; + initDispatcherWith("true"); + fieldName = "birthday"; + + assertNull(propertyTypeHandedToTheProvider()); + } + public void testAnIndexedFieldHandsTheProviderNoType() throws Exception { initDispatcherWith("true"); fieldName = "user.address[0].street"; @@ -494,6 +525,19 @@ public class ConstraintAttributesTest extends AbstractUITagTest { } } + public static class RenderingAction extends org.apache.struts2.ActionSupport { + + private String birthday; + + public String getBirthday() { + return birthday; + } + + public void setBirthday(String birthday) { + this.birthday = birthday; + } + } + public static class TemporalModel extends ConstraintAction { private java.time.LocalDate startDate; diff --git a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java index 92a067aac..6e96bde73 100644 --- a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java +++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java @@ -37,6 +37,7 @@ import org.junit.Test; import java.math.BigDecimal; import java.sql.Timestamp; +import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -392,6 +393,32 @@ public class StrutsHtmlConstraintProviderTest { } } + @Test + public void aSubclassOverridingAddRangeStillHandlesTheDateControl() { + provider = new StrutsHtmlConstraintProvider() { + @Override + protected void addRange(Map<String, String> attributes, RangeValidatorSupport<?> validator, + HtmlControlType control) { + attributes.put("data-range", control.name()); + } + }; + LocalDateRangeFieldValidator validator = new LocalDateRangeFieldValidator(); + validator.setMin(LocalDate.of(2026, 1, 1)); + + assertThat(constraints(validator, HtmlControlType.DATE)).containsOnly(Map.entry("data-range", "DATE")); + } + + @Test + public void dateRangeOmitsABoundBeforeTheGregorianCutoverWhereTheConverterReadsJulianDates() throws Exception { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + SimpleDateFormat converterFormat = new SimpleDateFormat("yyyy-MM-dd"); + DateRangeFieldValidator validator = new DateRangeFieldValidator(); + validator.setMin(converterFormat.parse("1500-01-01")); + validator.setMax(converterFormat.parse("2026-12-31")); + + assertThat(constraints(validator, HtmlControlType.DATE)).containsOnly(Map.entry("max", "2026-12-31")); + } + @Test public void localDateRangeOmitsABoundOutsideTheYearsHtmlCanWrite() { LocalDateRangeFieldValidator validator = new LocalDateRangeFieldValidator();
