View Javadoc

1   package com.atlassian.scheduler.core.tests;
2   
3   import com.atlassian.scheduler.cron.CronExpressionValidator;
4   import com.atlassian.scheduler.cron.CronSyntaxException;
5   import com.atlassian.scheduler.cron.ErrorCode;
6   import org.hamcrest.Description;
7   import org.hamcrest.Matcher;
8   import org.hamcrest.TypeSafeMatcher;
9   import org.junit.Test;
10  
11  import java.io.PrintWriter;
12  import java.io.StringWriter;
13  import java.util.Locale;
14  import java.util.Objects;
15  
16  import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_LAST_DOM;
17  import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_WEEKDAY_DOM;
18  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER;
19  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_INTERVAL;
20  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_QM;
21  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME;
22  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_DAY_OF_WEEK;
23  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_FIELD;
24  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_RANGE;
25  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_MONTH;
26  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_WEEK;
27  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_HOUR;
28  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_MONTH;
29  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_SEC_OR_MIN;
30  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_YEAR;
31  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_YEAR_RANGE;
32  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP;
33  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_MONTH;
34  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_WEEK;
35  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_HOUR;
36  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_MONTH;
37  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_SECOND_OR_MINUTE;
38  import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_FOR_BOTH_DAYS;
39  import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_HERE;
40  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_END_OF_EXPRESSION;
41  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_L;
42  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_W;
43  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_HASH;
44  import static org.junit.Assert.assertThat;
45  import static org.junit.Assert.fail;
46  
47  /**
48   * Core compatibility test for a CronExpressionValidator.
49   *
50   * @since v1.4
51   */
52  public abstract class CronExpressionValidatorTest {
53      protected CronExpressionValidator validator;
54  
55      @Test
56      public void testEmptyString() {
57          assertError("", UNEXPECTED_END_OF_EXPRESSION, "Unexpected end of expression", null, 0);
58      }
59  
60      @Test
61      public void testMissingFields() {
62          assertError("0 0 0 ? 1", UNEXPECTED_END_OF_EXPRESSION, "Unexpected end of expression", null, 9);
63      }
64  
65      @Test
66      public void testTruncatedExpression() {
67          assertError("0 0 0 ? 1 NO", INVALID_NAME_DAY_OF_WEEK, "Invalid day-of-week name: 'NO'", "NO", 10);
68      }
69  
70      @Test
71      public void testNameRanges() {
72          assertInvalidNameRange("0 0 0 ? 1 1-FRI", 12);
73          assertInvalidNameRange("0 0 0 ? 1 MON-4", 14);
74          assertInvalidNameRange("0 0 0 ? 1 MON-45", 14);
75          assertInvalidNameRange("0 0 0 ? 1 MON-456", 14);
76          assertInvalidNameRange("0 0 0 ? 1 MON-45F", 14);
77          assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-FFF", "FFF", 14);
78          assertValid("0 0 0 ? 1 MON-FRI");
79      }
80  
81      @Test
82      public void testNameRangesWrongLength() {
83          assertInvalidNameDayOfWeek("0 0 0 ? 1 M-FRI", "M", 10);
84          assertInvalidNameDayOfWeek("0 0 0 ? 1 MO-FRI", "MO", 10);
85          assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-F", "F", 14);
86          assertInvalidNameDayOfWeek("0 0 0 ? 1 MON-FR", "FR", 14);
87          assertInvalidName("0 0 0 WL 1 ?", "WL", 6);
88      }
89  
90      @Test
91      public void testCronDoesNotGrokThatCharacter() {
92          assertInvalidNameField("0 XXX 0 ? 1 MON-TUE", "XXX", 2);
93          assertErrorQuartzExempt("0 0XY 0 ? 1 MON-TUE", ILLEGAL_CHARACTER, "Unexpected character: 'X'", "X", 3,
94                  "the trailing 'XY' just gets ignored");
95          assertErrorQuartzExempt("0 0-3XY 0 ? 1 MON-TUE", ILLEGAL_CHARACTER, "Unexpected character: 'X'", "X", 5,
96                  "the trailing 'XY' just gets ignored");
97      }
98  
99      @Test
100     public void testQuestionMarkWithNonWhitespaceAfterIt() {
101         assertValid("0 0 0 ? 1 MON-TUE");
102         assertValid("0 0 0 ? 1 Mon-tUe");
103         assertValid("0 0 0 ?\t1 mon-tue");
104         assertErrorQuartzExempt("0 0 0 ?X 1 MON-TUE", ILLEGAL_CHARACTER_AFTER_QM, "Illegal character after '?': 'X'",
105                 "X", 7,
106                 "it has an off-by-one error in its parser and misses the X.");
107         assertError("0 0 0 ?XY 1 MON-TUE", ILLEGAL_CHARACTER_AFTER_QM, "Illegal character after '?': 'X'", "X", 7);
108     }
109 
110     @Test
111     public void testQuestionMarkInNonDayField() {
112         assertError("0 ? 0 ? 1 1", QM_CANNOT_USE_HERE,
113                 "You can only use '?' for the day-of-month or the day-of-week.", null, 2);
114     }
115 
116     @Test
117     public void testQuestionMarkInBothDayFields() {
118         assertError("0 0 0 ? 1 ?", QM_CANNOT_USE_FOR_BOTH_DAYS,
119                 "You cannot specify '?' for both the day-of-month and the day-of-week.", null, 10);
120     }
121 
122     @Test
123     public void testMisplacedCommas() {
124         assertError("0 0 0 L,3 1 ?", COMMA_WITH_LAST_DOM,
125                 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 7);
126         assertError("0 0 0 3,LW 1 ?", COMMA_WITH_LAST_DOM,
127                 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 8);
128         assertError("0 0 0 L-4W,7 1 ?", COMMA_WITH_LAST_DOM,
129                 "You cannot use 'L' or 'W' with multiple day-of-month values.", null, 10);
130         assertErrorQuartzExempt("0 0 0 11W,17,22-23 1 ?", COMMA_WITH_WEEKDAY_DOM,
131                 "You cannot use 'W' with multiple day-of-month values.", null, 9,
132                 "it interprets this as '11W'");
133         assertErrorQuartzExempt("0 0 0 13,17W,22-23 1 ?", COMMA_WITH_WEEKDAY_DOM,
134                 "You cannot use 'W' with multiple day-of-month values.", null, 11,
135                 "it interprets this as '13W'");
136         assertErrorQuartzExempt("0 0 0 13,17-19W,23 1 ?", COMMA_WITH_WEEKDAY_DOM,
137                 "You cannot use 'W' with multiple day-of-month values.", null, 14,
138                 "it ignores the 'W' flag entirely");
139     }
140 
141     @Test
142     public void testSupportForLastMinusNumberForDayOfMonth() {
143         assertValid("0 0 0 L-3 1 ?");
144         assertValid("0 0 0 L-4W 1 ?");
145     }
146 
147     @Test
148     public void testBadDayOfMonthExpressions() {
149         assertInvalidFlagL("0 0 0 L- 1 ?", 6);
150         assertInvalidFlagL("0 0 0 L-X 1 ?", 6);
151         assertInvalidFlagL("0 0 0 L-XW 1 ?", 6);
152         assertError("0 0 0 W-2 1 ?", UNEXPECTED_TOKEN_FLAG_W, "The 'W' option was used incorrectly.", null, 6);
153         assertInvalidNameField("0 0 0 XYZ 1 ?", "XYZ", 6);
154     }
155 
156     @Test
157     public void testLastFlagWithHyphenW() {
158         assertValid("0 0 0 L-W 1 ?");
159     }
160 
161     @Test
162     public void testLastFlagSpecifiedForUnsupportedField() {
163         assertErrorQuartzExempt("L 0 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_L, "The 'L' option was used incorrectly.", null, 0,
164                 "the invalid L is ignored, there are no values for seconds, and this never matches.");
165         assertErrorQuartzExempt("0 L 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_L, "The 'L' option was used incorrectly.", null, 2,
166                 "the invalid L is ignored, there are no values for minutes, and this never matches.");
167         assertInvalidFlagL("6L 0 0 1 1 ?", 1);
168     }
169 
170     @Test
171     public void testWeekdayFlagSpecifiedForUnsupportedField() {
172         assertValid("0 0 0 LW 1 ?");
173         assertValid("0 0 0 15W 1 ?");
174         assertError("6W 0 0 1 1 ?", UNEXPECTED_TOKEN_FLAG_W, "The 'W' option was used incorrectly.", null, 1);
175     }
176 
177     @Test
178     public void testHashOption() {
179         assertValid("0 0 0 ? 1 1#3");
180         assertValid("0 0 0 ? 1 MON#3");
181         assertError("6#4 0 0 1 1 ?", UNEXPECTED_TOKEN_HASH, "The '#' option was used incorrectly.", null, 1);
182     }
183 
184     @Test
185     public void testTrailingGarbageIgnoredIfYearsArePresent() {
186         assertValid("0 0 0 * 1 ? 2000-2099 Then a comment with spe\u00A9ial chars and *,L+& all of that.");
187         assertError("0 0 0 * 1 ? But not unless the year is given", INVALID_NAME_FIELD,
188                 "This field does not support names: 'BUT'", "BUT", 12);
189     }
190 
191     @Test
192     public void testNumericValueOutOfRange() {
193         assertError("65 0 0 ? 1 MON", INVALID_NUMBER_SEC_OR_MIN,
194                 "The values for seconds and minutes must be from 0 to 59.", null, 0);
195         assertError("0 65 0 ? 1 MON", INVALID_NUMBER_SEC_OR_MIN,
196                 "The values for seconds and minutes must be from 0 to 59.", null, 2);
197         assertError("0 0 26 ? 1 MON", INVALID_NUMBER_HOUR,
198                 "The values for hours must be from 0 to 23.", null, 4);
199         assertError("0 0 0 33 1 ?", INVALID_NUMBER_DAY_OF_MONTH,
200                 "The values for day-of-month must be from 1 to 31.", null, 6);
201         assertError("0 0 0 ? 15 MON", INVALID_NUMBER_MONTH,
202                 "The values for month must be from 1 to 12.", null, 8);
203         assertError("0 0 0 ? 1 9", INVALID_NUMBER_DAY_OF_WEEK,
204                 "The values for day-of-week must be from 1 to 7.", null, 10);
205         assertErrorQuartzExempt("0 0 0 ? 1 6 42", INVALID_NUMBER_YEAR,
206                 "The values for year must be from 1970 to 2299.", null, 12,
207                 "it does not check that the years are reasonable during the parse.");
208         assertErrorQuartzExempt("0 0 0 ? 1 6 9999", INVALID_NUMBER_YEAR,
209                 "The values for year must be from 1970 to 2299.", null, 12,
210                 "it does not check that the years are reasonable during the parse.");
211         assertError("0 0 0 ? 1 6 2036-2016", INVALID_NUMBER_YEAR_RANGE,
212                 "Year ranges must specify the earlier year first.", null, 17);
213     }
214 
215     @Test
216     public void testInvalidIncrements() {
217         assertError("0 / 0 ? 1 MON", INVALID_STEP,
218                 "The step interval character '/' must be followed by a positive integer.", null, 3);
219         assertError("/65 0 0 ? 1 MON", INVALID_STEP_SECOND_OR_MINUTE,
220                 "The step interval for second or minute must be less than 60: 65", "65", 1);
221         assertError("0 /65 0 ? 1 MON", INVALID_STEP_SECOND_OR_MINUTE,
222                 "The step interval for second or minute must be less than 60: 65", "65", 3);
223         assertError("0 0 /26 ? 1 MON", INVALID_STEP_HOUR,
224                 "The step interval for hour must be less than 24: 26", "26", 5);
225         assertError("0 0 0 /36 1 ?", INVALID_STEP_DAY_OF_MONTH,
226                 "The step interval for day-of-month must be less than 31: 36", "36", 7);
227         assertError("0 0 0 ? /15 MON", INVALID_STEP_MONTH,
228                 "The step interval for month must be less than 12: 15", "15", 9);
229         assertError("0 0 0 ? 1 /9", INVALID_STEP_DAY_OF_WEEK,
230                 "The step interval for day-of-week must be less than 7: 9", "9", 11);
231         assertError("0 0 0 1/A 1 ?", INVALID_STEP,
232                 "The step interval character '/' must be followed by a positive integer.", null, 8);
233         assertError("0 0 0 1/6A 1 ?", ILLEGAL_CHARACTER_AFTER_INTERVAL,
234                 "Illegal character after '/': 'A'", "A", 9);
235     }
236 
237     // Actual cron expressions encountered in the Atlassian Cloud that Quartz accepts but Caesium rejected
238     @Test
239     public void testRealWorldErrorsFromCloud() {
240         // Caesium bug that is now fixed
241         assertValid(" \t 0 0 0 ? 1 MON-TUE");
242 
243         // Quartz bugs
244         assertErrorQuartzExempt("0 0/120 8-16 ? * MON-FRI", INVALID_STEP_SECOND_OR_MINUTE,
245                 "The step interval for second or minute must be less than 60: 120", "120", 4,
246                 "it only validates the step interval if the range has an upper bound.");
247         assertErrorQuartzExempt("0 0/2 8-18 ? * MON-FRI *?", ILLEGAL_CHARACTER,
248                 "Unexpected character: '?'", "?", 24,
249                 "it silently ignores anything after the '*' that isn't a step interval.");
250         assertErrorQuartzExempt("0 40 9 1W,15W 1-12 ? 2010-2050", COMMA_WITH_WEEKDAY_DOM,
251                 "You cannot use 'W' with multiple day-of-month values.", null, 9,
252                 "it interprets this as '11W'");
253     }
254 
255 
256     protected void assertValid(final String cronExpression) {
257         try {
258             validator.validate(cronExpression);
259         } catch (CronSyntaxException cse) {
260             final Error e = new AssertionError("Expected '" + cronExpression + "' to be valid, but it threw " + cse);
261             e.initCause(cse);
262             throw e;
263         }
264     }
265 
266     protected void assertError(final String cronExpression, final ErrorCode errorCode, final String message,
267                                final String value, final int errorOffset) {
268         try {
269             validator.validate(cronExpression);
270             fail("Expected a CronSyntaxException with message=\"" + message + "\" and errorOffset=" + errorOffset);
271         } catch (CronSyntaxException cse) {
272             assertThat(cse, exception(cronExpression.toUpperCase(Locale.US), errorCode, message, value, errorOffset));
273         }
274     }
275 
276     /**
277      * Factored out into its own method because the Quartz implementations frequently return {@code -1} instead
278      * of the correct error offset.
279      *
280      * @param expected the expected error offset
281      * @param cse      the actual exception
282      */
283     protected boolean verifyErrorOffset(final int expected, final CronSyntaxException cse) {
284         return expected == cse.getErrorOffset();
285     }
286 
287     /**
288      * Assert that the given cron expression is invalid, but that the Quartz implementations will incorrectly
289      * say it is valid.
290      * <p>
291      * It's really sad that this is necessary, but there are just so many places where Quartz is just plain
292      * wrong that doing this any other way would be even more confusing.
293      * </p>
294      *
295      * @param cronExpression   the invalid cron expression
296      * @param errorCode        the expected error code
297      * @param message          the expected error message
298      * @param value            the expected token value
299      * @param pos              the expected error offset
300      * @param whyQuartzIsWrong an explanation as to what Quartz does instead and why it is wrong
301      */
302     protected void assertErrorQuartzExempt(final String cronExpression, final ErrorCode errorCode, final String message,
303                                            final String value, final int pos, final String whyQuartzIsWrong) {
304         assertError(cronExpression, errorCode, message, value, pos);
305     }
306 
307     protected void assertInvalidFlagL(final String cronExpression, final int errorOffset) {
308         assertError(cronExpression, UNEXPECTED_TOKEN_FLAG_L,
309                 "The 'L' option was used incorrectly.",
310                 null,
311                 errorOffset);
312     }
313 
314     protected void assertInvalidName(final String cronExpression, final String name, final int errorOffset) {
315         assertError(cronExpression, INVALID_NAME,
316                 "Invalid name: '" + name + '\'',
317                 name,
318                 errorOffset);
319     }
320 
321     protected void assertInvalidNameField(final String cronExpression, final String name, final int errorOffset) {
322         assertError(cronExpression, INVALID_NAME_FIELD,
323                 "This field does not support names: '" + name + '\'',
324                 name,
325                 errorOffset);
326     }
327 
328     protected void assertInvalidNameRange(final String cronExpression, final int errorOffset) {
329         assertError(cronExpression, INVALID_NAME_RANGE,
330                 "Cannot specify a range using month or day-of-week names unless a valid name is used for both bounds.",
331                 null,
332                 errorOffset);
333     }
334 
335     protected void assertInvalidNameDayOfWeek(final String cronExpression, final String name, final int errorOffset) {
336         assertError(cronExpression, INVALID_NAME_DAY_OF_WEEK,
337                 "Invalid day-of-week name: '" + name + '\'',
338                 name,
339                 errorOffset);
340     }
341 
342     protected Matcher<CronSyntaxException> exception(final String cronExpression, final ErrorCode errorCode,
343                                                      final String message, final String value, final int errorOffset) {
344         return new CronSyntaxExceptionMatcher(cronExpression, errorCode, message, value, errorOffset);
345     }
346 
347     protected static String stackTrace(Throwable e) {
348         final StringWriter sw = new StringWriter();
349         final PrintWriter pw = new PrintWriter(sw, false);
350         e.printStackTrace(pw);
351         pw.flush();
352         return sw.toString();
353     }
354 
355 
356     class CronSyntaxExceptionMatcher extends TypeSafeMatcher<CronSyntaxException> {
357         private final String cronExpression;
358         private final ErrorCode errorCode;
359         private final String message;
360         private final String value;
361         private final int errorOffset;
362 
363         CronSyntaxExceptionMatcher(String cronExpression, ErrorCode errorCode, String message, String value, int errorOffset) {
364             this.cronExpression = cronExpression;
365             this.errorCode = errorCode;
366             this.message = message;
367             this.value = value;
368             this.errorOffset = errorOffset;
369         }
370 
371         @Override
372         protected boolean matchesSafely(CronSyntaxException cse) {
373             return Objects.equals(cronExpression, cse.getCronExpression())
374                     && errorCode == cse.getErrorCode()
375                     && Objects.equals(message, cse.getMessage())
376                     && Objects.equals(value, cse.getValue())
377                     && verifyErrorOffset(errorOffset, cse);
378         }
379 
380         @Override
381         public void describeTo(Description description) {
382             description.appendText("a CronSyntaxException with:\n\tcronExpression: ").appendText(cronExpression)
383                     .appendText("\n\t\terrorCode: ").appendText(errorCode.name())
384                     .appendText("\n\t\tmessage: ").appendText(message)
385                     .appendText("\n\t\tvalue: ").appendText(value)
386                     .appendText("\n\t\terrorOffset: ").appendText(String.valueOf(errorOffset));
387         }
388 
389         @Override
390         protected void describeMismatchSafely(CronSyntaxException cse, Description description) {
391             description.appendText("a CronSyntaxException with:\n\tcronExpression: ").appendText(cse.getCronExpression())
392                     .appendText("\n\t\terrorCode: ").appendText(cse.getErrorCode().name())
393                     .appendText("\n\t\tmessage: ").appendText(cse.getMessage())
394                     .appendText("\n\t\tvalue: ").appendText(cse.getValue())
395                     .appendText("\n\t\terrorOffset: ").appendText(String.valueOf(cse.getErrorOffset()))
396                     .appendText("\n").appendText(stackTrace(cse))
397                     .appendText("\n");
398         }
399     }
400 }
401