Skip to content

Commit fd1f0bf

Browse files
fix: reject invalid/negative lastUpdatedDuration on dataValueSets export instead of silently ignoring it (#24470)
* fix: reject invalid/negative lastUpdatedDuration on dataValueSets export instead of silently ignoring it DateUtils.getDuration() returns null on any parse failure (its regex requires unsigned digits, so negative durations like "-5d" never match), and DefaultDataExportService.decodeParams() called it unchecked, so a malformed or negative lastUpdatedDuration was silently discarded - indistinguishable from "not provided". If it was the client's only time filter, this surfaced as the unrelated E2002 error instead of pointing at the actual problem; otherwise it was dropped with no error at all. ErrorCode.E2005 ("Duration is not valid: `{0}`") already exists for exactly this and is used by the sibling DefaultCompleteDataSetRegistrationExchangeService for its createdDuration parameter, but was never wired up here. decodeParams() now throws ConflictException(E2005, rawValue) when lastUpdatedDuration is non-blank but fails to parse. lastUpdatedDuration=0d is deliberately left valid - it parses successfully (Duration.ZERO) and is a well-defined (if narrow) "as of right now" filter, analogous to startDate == endDate being a valid zero-width date range. Verified via mutation testing: temporarily disabled the new guard, confirmed only the two negative-case tests failed (the 0d-is-valid test stayed green), then restored. No regressions in DataExportServiceExportTest, DataValueServiceTest, or DataValueSetControllerTest. Related to DHIS2-21821. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: reduce decodeParams cognitive complexity and cover blank duration branch Extracts attribute-option-combo, order, and lastUpdatedDuration resolution into their own methods so decodeParams stays under the complexity limit, and adds a test for the blank (non-null) lastUpdatedDuration case that the isBlank() guard exists to handle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ab98e01 commit fd1f0bf

2 files changed

Lines changed: 157 additions & 22 deletions

File tree

dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/datavalueset/DefaultDataExportService.java

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import static org.hisp.dhis.user.CurrentUserUtil.getCurrentUserDetails;
4040
import static org.hisp.dhis.util.DateUtils.toLongGmtDate;
4141

42+
import java.time.Duration;
4243
import java.util.ArrayList;
4344
import java.util.Collection;
4445
import java.util.Iterator;
@@ -345,7 +346,7 @@ private static String emptyAsNull(String value) {
345346
return value == null || value.isEmpty() ? null : value;
346347
}
347348

348-
private DataExportParams decodeParams(DataExportParams.Input params) {
349+
private DataExportParams decodeParams(DataExportParams.Input params) throws ConflictException {
349350
boolean codeFallback = Boolean.TRUE.equals(params.getInputUseCodeFallback());
350351
IdentifiableProperty anyIn = params.getInputIdScheme();
351352
IdentifiableProperty dsIn = params.getInputDataSetIdScheme();
@@ -358,24 +359,6 @@ private DataExportParams decodeParams(DataExportParams.Input params) {
358359
if (ouIn == null) ouIn = anyIn;
359360
if (degIn == null) degIn = anyIn;
360361

361-
List<UID> attributeOptionCombos = decodeIds(COC, anyIn, params.getAttributeOptionCombo());
362-
if (attributeOptionCombos.isEmpty() && params.getAttributeOptions() != null) {
363-
UID aoc =
364-
store.getAttributeOptionCombo(
365-
params.getAttributeCombo(), params.getAttributeOptions().stream());
366-
if (aoc != null) attributeOptionCombos = List.of(aoc);
367-
}
368-
369-
List<Order> orders = params.getOrder();
370-
if (params.isOrderByPeriod()) {
371-
if (orders == null) {
372-
orders = List.of(Order.PE);
373-
} else if (!orders.contains(Order.PE)) {
374-
orders = new ArrayList<>(orders);
375-
orders.add(0, Order.PE);
376-
}
377-
}
378-
379362
return DataExportParams.builder()
380363
.dataSets(decodeIds(DS, dsIn, params.getDataSet()))
381364
.dataElementGroups(decodeIds(DEG, degIn, params.getDataElementGroup()))
@@ -384,20 +367,54 @@ private DataExportParams decodeParams(DataExportParams.Input params) {
384367
.organisationUnitGroups(decodeIds(OUG, anyIn, params.getOrgUnitGroup()))
385368
.orgUnitLevel(params.getLevel())
386369
.categoryOptionCombos(decodeIds(COC, anyIn, params.getCategoryOptionCombo()))
387-
.attributeOptionCombos(attributeOptionCombos)
370+
.attributeOptionCombos(resolveAttributeOptionCombos(anyIn, params))
388371
.periods(decodePeriods(params.getPeriod()))
389372
.startDate(params.getStartDate())
390373
.endDate(params.getEndDate())
391374
.includeDescendants(params.isChildren())
392375
.includeDeleted(params.isIncludeDeleted())
393376
.lastUpdated(params.getLastUpdated())
394-
.lastUpdatedDuration(DateUtils.getDuration(params.getLastUpdatedDuration()))
377+
.lastUpdatedDuration(resolveLastUpdatedDuration(params))
395378
.limit(params.getLimit())
396379
.offset(params.getOffset())
397-
.orders(orders)
380+
.orders(resolveOrders(params))
398381
.build();
399382
}
400383

384+
@Nonnull
385+
private List<UID> resolveAttributeOptionCombos(
386+
IdentifiableProperty anyIn, DataExportParams.Input params) {
387+
List<UID> attributeOptionCombos = decodeIds(COC, anyIn, params.getAttributeOptionCombo());
388+
if (!attributeOptionCombos.isEmpty() || params.getAttributeOptions() == null) {
389+
return attributeOptionCombos;
390+
}
391+
UID aoc =
392+
store.getAttributeOptionCombo(
393+
params.getAttributeCombo(), params.getAttributeOptions().stream());
394+
return aoc == null ? attributeOptionCombos : List.of(aoc);
395+
}
396+
397+
private List<Order> resolveOrders(DataExportParams.Input params) {
398+
List<Order> orders = params.getOrder();
399+
if (!params.isOrderByPeriod()) return orders;
400+
if (orders == null) return List.of(Order.PE);
401+
if (orders.contains(Order.PE)) return orders;
402+
orders = new ArrayList<>(orders);
403+
orders.add(0, Order.PE);
404+
return orders;
405+
}
406+
407+
private Duration resolveLastUpdatedDuration(DataExportParams.Input params)
408+
throws ConflictException {
409+
String lastUpdatedDurationIn = params.getLastUpdatedDuration();
410+
Duration lastUpdatedDuration = DateUtils.getDuration(lastUpdatedDurationIn);
411+
if (lastUpdatedDurationIn != null
412+
&& !lastUpdatedDurationIn.isBlank()
413+
&& lastUpdatedDuration == null)
414+
throw new ConflictException(ErrorCode.E2005, lastUpdatedDurationIn);
415+
return lastUpdatedDuration;
416+
}
417+
401418
@Nonnull
402419
private List<UID> decodeIds(
403420
IdCoder.ObjectType type, @CheckForNull IdentifiableProperty from, Collection<String> ids) {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright (c) 2004-2022, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.dxf2.datavalueset;
31+
32+
import static org.hisp.dhis.test.TestBase.clearSecurityContext;
33+
import static org.hisp.dhis.test.TestBase.injectSecurityContextNoSettings;
34+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
35+
import static org.junit.jupiter.api.Assertions.assertEquals;
36+
import static org.junit.jupiter.api.Assertions.assertThrows;
37+
38+
import java.util.Set;
39+
import org.hisp.dhis.common.IdCoder;
40+
import org.hisp.dhis.common.UID;
41+
import org.hisp.dhis.datavalue.DataExportParams;
42+
import org.hisp.dhis.datavalue.DataExportStore;
43+
import org.hisp.dhis.feedback.ConflictException;
44+
import org.hisp.dhis.feedback.ErrorCode;
45+
import org.hisp.dhis.user.SystemUser;
46+
import org.junit.jupiter.api.AfterEach;
47+
import org.junit.jupiter.api.BeforeEach;
48+
import org.junit.jupiter.api.Test;
49+
import org.junit.jupiter.api.extension.ExtendWith;
50+
import org.mockito.Mock;
51+
import org.mockito.junit.jupiter.MockitoExtension;
52+
53+
@ExtendWith(MockitoExtension.class)
54+
class DefaultDataExportServiceTest {
55+
56+
@Mock private DataExportStore store;
57+
58+
@Mock private IdCoder idCoder;
59+
60+
@BeforeEach
61+
void setUp() {
62+
injectSecurityContextNoSettings(new SystemUser());
63+
}
64+
65+
@AfterEach
66+
void tearDown() {
67+
clearSecurityContext();
68+
}
69+
70+
private DataExportParams.Input.InputBuilder validFiltersBuilder() {
71+
return DataExportParams.Input.builder()
72+
.dataSet(Set.of(UID.generate().getValue()))
73+
.orgUnit(Set.of(UID.generate().getValue()))
74+
.period(Set.of("202201"));
75+
}
76+
77+
private DefaultDataExportService service() {
78+
return new DefaultDataExportService(store, idCoder);
79+
}
80+
81+
@Test
82+
void testExportValues_InvalidLastUpdatedDurationThrowsE2005() {
83+
DataExportParams.Input params = validFiltersBuilder().lastUpdatedDuration("-5d").build();
84+
85+
ConflictException ex =
86+
assertThrows(ConflictException.class, () -> service().exportValues(params));
87+
88+
assertEquals(ErrorCode.E2005, ex.getCode());
89+
}
90+
91+
@Test
92+
void testExportValues_MalformedLastUpdatedDurationThrowsE2005() {
93+
DataExportParams.Input params = validFiltersBuilder().lastUpdatedDuration("abc").build();
94+
95+
ConflictException ex =
96+
assertThrows(ConflictException.class, () -> service().exportValues(params));
97+
98+
assertEquals(ErrorCode.E2005, ex.getCode());
99+
}
100+
101+
@Test
102+
void testExportValues_ZeroDurationIsValid() {
103+
// 0d parses successfully (Duration.ZERO); it's a degenerate "as of right now" filter,
104+
// not a parse failure, so it must not be rejected the same way "-5d"/"abc" are.
105+
DataExportParams.Input params = validFiltersBuilder().lastUpdatedDuration("0d").build();
106+
107+
assertDoesNotThrow(() -> service().exportValues(params));
108+
}
109+
110+
@Test
111+
void testExportValues_BlankLastUpdatedDurationIsIgnored() {
112+
// a blank (but non-null) value never reaches the parser, so it must not be
113+
// treated as unparseable and rejected like "-5d"/"abc" are.
114+
DataExportParams.Input params = validFiltersBuilder().lastUpdatedDuration(" ").build();
115+
116+
assertDoesNotThrow(() -> service().exportValues(params));
117+
}
118+
}

0 commit comments

Comments
 (0)