55package dev .learning .xapi .model ;
66
77import static org .hamcrest .MatcherAssert .assertThat ;
8+ import static org .hamcrest .Matchers .empty ;
9+ import static org .hamcrest .Matchers .hasSize ;
810import static org .hamcrest .Matchers .instanceOf ;
911import static org .hamcrest .Matchers .is ;
12+ import static org .hamcrest .Matchers .not ;
1013
1114import com .fasterxml .jackson .databind .ObjectMapper ;
15+ import jakarta .validation .ConstraintViolation ;
16+ import jakarta .validation .Validation ;
17+ import jakarta .validation .Validator ;
1218import java .io .IOException ;
19+ import java .util .Set ;
1320import org .junit .jupiter .api .DisplayName ;
1421import org .junit .jupiter .api .Test ;
22+ import org .junit .jupiter .params .ParameterizedTest ;
23+ import org .junit .jupiter .params .provider .ValueSource ;
1524import org .springframework .util .ResourceUtils ;
1625
1726/**
@@ -25,6 +34,8 @@ class ResultTests {
2534
2635 private final ObjectMapper objectMapper = new ObjectMapper ().findAndRegisterModules ();
2736
37+ private final Validator validator = Validation .buildDefaultValidatorFactory ().getValidator ();
38+
2839 @ Test
2940 void whenDeserializingResultThenResultIsInstanceOfResult () throws Exception {
3041
@@ -139,4 +150,184 @@ void whenCallingToStringThenResultIsExpected() {
139150 "Result(score=Score(scaled=1.0, raw=1.0, min=0.0, max=5.0), success=true, completion=true, "
140151 + "response=test, duration=P1D, extensions=null)" ));
141152 }
153+
154+ // Duration Validation Tests
155+
156+ @ ParameterizedTest
157+ @ ValueSource (
158+ strings = {
159+ // Week format
160+ "P1W" ,
161+ "P52W" ,
162+ "P104W" ,
163+ // Day format
164+ "P1D" ,
165+ "P365D" ,
166+ // Time format
167+ "PT1H" ,
168+ "PT30M" ,
169+ "PT45S" ,
170+ "PT1.5S" ,
171+ "PT0.5S" ,
172+ // Combined date format
173+ "P1Y" ,
174+ "P1M" ,
175+ "P1Y2M" ,
176+ "P1Y2M3D" ,
177+ // Combined date and time format
178+ "P1DT1H" ,
179+ "P1DT1H30M" ,
180+ "P1DT1H30M45S" ,
181+ "P1Y2M3DT4H5M6S" ,
182+ "P1Y2M3DT4H5M6.7S" ,
183+ // Minimal valid formats
184+ "PT0S" ,
185+ "PT1S" ,
186+ "P0D" ,
187+ // Real-world examples
188+ "PT8H" ,
189+ "P90D" ,
190+ "P2Y" ,
191+ "PT15M30S"
192+ })
193+ @ DisplayName ("When Duration Is Valid Then Validation Passes" )
194+ void whenDurationIsValidThenValidationPasses (String duration ) {
195+
196+ // Given Result With Valid Duration
197+ final var result = Result .builder ().duration (duration ).build ();
198+
199+ // When Validating Result
200+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
201+
202+ // Then Validation Passes
203+ assertThat (violations , empty ());
204+ }
205+
206+ @ ParameterizedTest
207+ @ ValueSource (
208+ strings = {
209+ // Invalid formats
210+ "" ,
211+ "T1H" ,
212+ "1D" ,
213+ "PD" ,
214+ "PT" ,
215+ "P1" ,
216+ "1Y2M" ,
217+ // Invalid time without T
218+ "P1H" ,
219+ "P1M30S" ,
220+ // Invalid mixing weeks with other units
221+ "P1W1D" ,
222+ "P1W1Y" ,
223+ "P1WT1H" ,
224+ // Invalid decimal placement
225+ "P1.5D" ,
226+ "P1.5Y" ,
227+ "PT1.5H" ,
228+ "PT1.5M" ,
229+ // Missing P prefix
230+ "1Y2M3D" ,
231+ "T1H30M" ,
232+ // Invalid order
233+ "P1D1Y" ,
234+ "PT1S1M" ,
235+ "PT1M1H" ,
236+ // Double separators
237+ "P1Y2M3DTT1H" ,
238+ "PP1D" ,
239+ // Negative values
240+ "P-1D" ,
241+ "PT-1H"
242+ })
243+ @ DisplayName ("When Duration Is Invalid Then Validation Fails" )
244+ void whenDurationIsInvalidThenValidationFails (String duration ) {
245+
246+ // Given Result With Invalid Duration
247+ final var result = Result .builder ().duration (duration ).build ();
248+
249+ // When Validating Result
250+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
251+
252+ // Then Validation Fails
253+ assertThat (violations , not (empty ()));
254+ assertThat (violations , hasSize (1 ));
255+ }
256+
257+ @ Test
258+ @ DisplayName ("When Duration Is Null Then Validation Passes" )
259+ void whenDurationIsNullThenValidationPasses () {
260+
261+ // Given Result With Null Duration
262+ final var result = Result .builder ().duration (null ).build ();
263+
264+ // When Validating Result
265+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
266+
267+ // Then Validation Passes
268+ assertThat (violations , empty ());
269+ }
270+
271+ @ Test
272+ @ DisplayName ("When Duration Has Many Digits Then Validation Completes Quickly" )
273+ void whenDurationHasManyDigitsThenValidationCompletesQuickly () {
274+
275+ // Given Result With Long But Valid Duration
276+ final var result = Result .builder ().duration ("P99999Y99999M99999DT99999H99999M99999S" ).build ();
277+
278+ // When Validating Result
279+ final long startTime = System .nanoTime ();
280+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
281+ final long endTime = System .nanoTime ();
282+ final long durationMs = (endTime - startTime ) / 1_000_000 ;
283+
284+ // Then Validation Passes And Completes In Reasonable Time
285+ assertThat (violations , empty ());
286+ // Validation should complete quickly - 500ms is generous for a regex match
287+ assertThat ("Validation should complete in less than 500ms" , durationMs < 500 );
288+ }
289+
290+ @ Test
291+ @ DisplayName ("When Duration Is Adversarial Input Then Validation Completes Quickly Without ReDoS" )
292+ void whenDurationIsAdversarialInputThenValidationCompletesQuicklyWithoutReDoS () {
293+
294+ // Given Result With Adversarial Input That Could Cause ReDoS
295+ // This input is designed to trigger exponential backtracking in vulnerable regex patterns
296+ final var adversarialInput = "P" + "9" .repeat (50 ) + "!" ;
297+ final var result = Result .builder ().duration (adversarialInput ).build ();
298+
299+ // When Validating Result
300+ final long startTime = System .nanoTime ();
301+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
302+ final long endTime = System .nanoTime ();
303+ final long durationMs = (endTime - startTime ) / 1_000_000 ;
304+
305+ // Then Validation Fails Quickly (not vulnerable to ReDoS)
306+ assertThat (violations , not (empty ()));
307+ // Validation should complete quickly even with adversarial input - 500ms is generous
308+ assertThat (
309+ "Validation should complete in less than 500ms even with adversarial input" ,
310+ durationMs < 500 );
311+ }
312+
313+ @ Test
314+ @ DisplayName ("When Duration Has Multiple Optional Groups Then Validation Completes Quickly" )
315+ void whenDurationHasMultipleOptionalGroupsThenValidationCompletesQuickly () {
316+
317+ // Given Result With Input That Tests Multiple Optional Groups
318+ // This tests the pattern with input that doesn't match but exercises optional groups
319+ final var testInput = "PYMDTHMS" ;
320+ final var result = Result .builder ().duration (testInput ).build ();
321+
322+ // When Validating Result
323+ final long startTime = System .nanoTime ();
324+ final Set <ConstraintViolation <Result >> violations = validator .validate (result );
325+ final long endTime = System .nanoTime ();
326+ final long durationMs = (endTime - startTime ) / 1_000_000 ;
327+
328+ // Then Validation Fails Quickly Without Excessive Backtracking
329+ assertThat (violations , not (empty ()));
330+ // Validation should complete quickly - 500ms is generous for a regex match
331+ assertThat ("Validation should complete in less than 500ms" , durationMs < 500 );
332+ }
142333}
0 commit comments