View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.validator.routines;
18  
19  import static org.junit.jupiter.api.Assertions.assertEquals;
20  import static org.junit.jupiter.api.Assertions.assertFalse;
21  import static org.junit.jupiter.api.Assertions.assertNotNull;
22  import static org.junit.jupiter.api.Assertions.assertNull;
23  import static org.junit.jupiter.api.Assertions.assertThrows;
24  import static org.junit.jupiter.api.Assertions.assertTrue;
25  import static org.junit.jupiter.api.Assertions.fail;
26  
27  import java.io.Reader;
28  import java.nio.charset.Charset;
29  import java.nio.file.Files;
30  import java.nio.file.Path;
31  import java.nio.file.Paths;
32  import java.util.ArrayList;
33  import java.util.Arrays;
34  import java.util.Collection;
35  import java.util.Date;
36  import java.util.List;
37  import java.util.Locale;
38  import java.util.regex.Matcher;
39  import java.util.regex.Pattern;
40  import java.util.stream.Collectors;
41  import java.util.stream.Stream;
42  
43  import org.apache.commons.csv.CSVFormat;
44  import org.apache.commons.csv.CSVParser;
45  import org.apache.commons.csv.CSVRecord;
46  import org.apache.commons.validator.routines.IBANValidator.Validator;
47  import org.apache.commons.validator.routines.checkdigit.IBANCheckDigit;
48  import org.junit.jupiter.api.Assumptions;
49  import org.junit.jupiter.api.Test;
50  import org.junit.jupiter.params.ParameterizedTest;
51  import org.junit.jupiter.params.provider.Arguments;
52  import org.junit.jupiter.params.provider.FieldSource;
53  import org.junit.jupiter.params.provider.MethodSource;
54  
55  /**
56   * Tests {@link IBANValidator}.
57   */
58  class IBANValidatorTest {
59  
60      private static final IBANValidator VALIDATOR = IBANValidator.getInstance();
61  
62      // Unfortunately Java only returns the last match of repeated patterns
63      // Use a manual repeat instead
64      private static final String IBAN_PART = "(?:(\\d+)!([acn]))"; // Assume all parts are fixed length
65  
66      private static final Pattern IBAN_PAT = Pattern
67              .compile(IBAN_PART + IBAN_PART + IBAN_PART + IBAN_PART + "?" + IBAN_PART + "?" + IBAN_PART + "?" + IBAN_PART + "?");
68  
69      /*
70       * The IBAN registry should be available from here:
71       * https://www.swift.com/standards/data-standards/iban-international-bank-account-number
72       * Care must be taken not to accidentally change the encoding, which for v99 appears to be Windows-1252 (cp1252)
73       * (even this encoding may not properly account for all characters)
74       * Please ensure you download from the page (right-click), and do not edit the file after download, as that may
75       * change the contents.
76       * At present the code does not need the entries which are likely to contain non-ASCII characters, but a corrupt
77       * file helps no-one.
78       */
79      private static final String IBAN_REGISTRY = "iban_registry_v99.txt";
80      private static final Charset IBAN_REGISTRY_CHARSET = Charset.forName("windows-1252");
81      private static final int MS_PER_DAY = 1000 * 60 * 60 * 24;
82      private static final long MAX_AGE_DAYS = 180; // how old registry can get (approx 6 months)
83  
84  
85  
86      // It's not clear whether IBANs can contain lower case characters
87      // so we test for both where possible
88      // Note that the BIC near the start of the code is always upper case or digits
89      // @formatter:off
90      private static final List<String> VALID_IBAN_FIXTURES = Arrays.asList(
91              "AD1200012030200359100100",
92              "AE070331234567890123456",
93              "AL47212110090000000235698741",
94              "AT611904300234573201",
95              "AZ21NABZ00000000137010001944",
96              "BA391290079401028494",
97              "BE68539007547034",
98              "BG80BNBG96611020345678",
99              "BH67BMAG00001299123456",
100             "BI4210000100010000332045181",
101             "BR1800000000141455123924100C2",
102             "BR1800360305000010009795493C1",
103             "BR9700360305000010009795493P1",
104             "BY13NBRB3600900000002Z00AB00",
105             "CH9300762011623852957",
106             "CR05015202001026284066",
107             "CY17002001280000001200527600",
108             "CZ6508000000192000145399",
109             "CZ9455000000001011038930",
110             "DE89370400440532013000",
111             "DJ2110002010010409943020008",
112             "DK5000400440116243",
113             "DO28BAGR00000001212453611324",
114             "EE382200221020145685",
115             "EG380019000500000000263180002",
116             "ES9121000418450200051332",
117             "FI2112345600000785",
118             "FI5542345670000081",
119               // FI other
120               "AX2112345600000785", // FI other
121               "AX5542345670000081", // FI other
122             "FK88SC123456789012",
123             "FO6264600001631634",
124             "FR1420041010050500013M02606",
125               // FR 'other'
126               "BL6820041010050500013M02606", // FR other
127               "GF4120041010050500013M02606", // FR other
128               "GP1120041010050500013M02606", // FR other
129               "MF8420041010050500013M02606", // FR other
130               "MQ5120041010050500013M02606", // FR other
131               "NC8420041010050500013M02606", // FR other
132               "PF5720041010050500013M02606", // FR other
133               "PM3620041010050500013M02606", // FR other
134               "RE4220041010050500013M02606", // FR other
135               "TF2120041010050500013M02606", // FR other
136               "WF9120041010050500013M02606", // FR other
137               "YT3120041010050500013M02606", // FR other
138             "GB29NWBK60161331926819",
139               // GB 'other'
140 //              "IM...", // GB other
141 //              "JE...", // GB other
142 //              "GG...", // GB other
143             "GE29NB0000000101904917",
144             "GI75NWBK000000007099453",
145             "GL8964710001000206",
146             "GR1601101250000000012300695",
147             "GT82TRAJ01020000001210029690",
148             "HN88CABF00000000000250005469",
149             "HR1210010051863000160",
150             "HU42117730161111101800000000",
151             "IE29AIBK93115212345678",
152             "IL620108000000099999999",
153             "IQ98NBIQ850123456789012",
154             "IS140159260076545510730339",
155             "IT60X0542811101000000123456",
156             "JO94CBJO0010000000000131000302",
157             "KW81CBKU0000000000001234560101",
158             "KZ86125KZT5004100100",
159             "LB62099900000001001901229114",
160             "LC55HEMM000100010012001200023015",
161             "LI21088100002324013AA",
162             "LT121000011101001000",
163             "LU280019400644750000",
164             "LY83002048000020100120361",
165             "LV80BANK0000435195001",
166             "LY83002048000020100120361",
167             "MC5811222000010123456789030",
168             "MD24AG000225100013104168",
169             "ME25505000012345678951",
170             "MK07250120000058984",
171             "MN121234123456789123",
172             "MR1300020001010000123456753",
173             "MT84MALT011000012345MTLCAST001S",
174             "MU17BOMM0101101030300200000MUR",
175             "NI45BAPR00000013000003558124",
176             "NL91ABNA0417164300",
177             "NO9386011117947",
178             "OM810180000001299123456",
179             "PK36SCBL0000001123456702",
180             "PL61109010140000071219812874",
181             "PS92PALS000000000400123456702",
182             "PT50000201231234567890154",
183             "QA58DOHB00001234567890ABCDEFG",
184             "RO49AAAA1B31007593840000",
185             "RS35260005601001611379",
186             "RU0204452560040702810412345678901",
187             "SA0380000000608010167519",
188             "SC18SSCB11010000000000001497USD",
189             "SD8811123456789012",
190             "SE4550000000058398257466",
191             "SI56191000000123438",
192             "SI56263300012039086",
193             "SK3112000000198742637541",
194             "SM86U0322509800000000270100",
195             "SO211000001001000100141",
196             "ST68000100010051845310112",
197             "SV62CENR00000000000000700025",
198             "SV43ACAT00000000000000123123",
199             "TL380080012345678910157",
200             "TN5910006035183598478831",
201             "TR330006100519786457841326",
202             "UA213223130000026007233566001",
203             "UA213996220000026007233566001",
204             "VA59001123000012345678",
205             "VG96VPVG0000012345678901",
206             "XK051212012345678906",
207             "YE15CBYE0001018861234567891234"
208     );
209     // @formatter:on
210 
211     // @formatter:off
212     private static final List<String> INVALID_IBAN_FIXTURES = Arrays.asList(
213             "",                        // empty
214             "   ",                     // empty
215             "A",                       // too short
216             "AB",                      // too short
217             "FR1420041010050500013m02606", // lowercase version
218             "MT84MALT011000012345mtlcast001s", // lowercase version
219             "LI21088100002324013aa", // lowercase version
220             "QA58DOHB00001234567890abcdefg", // lowercase version
221             "RO49AAAA1b31007593840000", // lowercase version
222             "LC62HEMM000100010012001200023015", // wrong in SWIFT
223             "BY00NBRB3600000000000Z00AB00", // Wrong in SWIFT v73
224             "ST68000200010192194210112", // ditto - invalid example
225             "SV62CENR0000000000000700025", // ditto
226             "NI04BAPR00000013000003558124", // invalid example
227             "RU1704452522540817810538091310419" // invalid example
228     );
229     // @formatter:on
230 
231     private static String fmtRE(final String ibanPat, final int ibanLength) {
232         final Matcher m = IBAN_PAT.matcher(ibanPat);
233         if (!m.matches()) {
234             throw new IllegalArgumentException("Unexpected IBAN pattern " + ibanPat);
235         }
236         final StringBuilder sb = new StringBuilder();
237         int len = Integer.parseInt(m.group(1)); // length of part
238         int totLen = len;
239         String curType = m.group(2); // part type
240         for (int i = 3; i <= m.groupCount(); i += 2) {
241             if (m.group(i) == null) { // reached an optional group
242                 break;
243             }
244             final int count = Integer.parseInt(m.group(i));
245             totLen += count;
246             final String type = m.group(i + 1);
247             if (type.equals(curType)) { // more of the same type
248                 len += count;
249             } else {
250                 sb.append(formatToRE(curType, len));
251                 curType = type;
252                 len = count;
253             }
254         }
255         sb.append(formatToRE(curType, len));
256         assertEquals(ibanLength, totLen, "Wrong length for " + ibanPat);
257         return sb.toString();
258     }
259 
260     // convert IBAN type string and length to regex
261     private static String formatToRE(final String type, final int len) {
262         final char ctype = type.charAt(0); // assume type.length() == 1
263         switch (ctype) {
264         case 'n':
265             return String.format("\\d{%d}", len);
266         case 'a':
267             return String.format("[A-Z]{%d}", len);
268         case 'c':
269             return String.format("[A-Z0-9]{%d}", len);
270         default:
271             throw new IllegalArgumentException("Unexpected type " + type);
272         }
273     }
274 
275     static Collection<Arguments> ibanRegistrySource() throws Exception {
276         final Path ibanRegistry = Paths.get(IBANValidator.class.getResource(IBAN_REGISTRY).toURI());
277 
278         final CSVFormat format = CSVFormat.DEFAULT.builder().setDelimiter('\t').build();
279         final Reader rdr = Files.newBufferedReader(ibanRegistry, IBAN_REGISTRY_CHARSET);
280 
281         CSVRecord country = null;
282         CSVRecord cc = null;
283         CSVRecord additionalCc = null;
284         CSVRecord structure = null;
285         CSVRecord length = null;
286 
287         try (CSVParser p = new CSVParser(rdr, format)) {
288             for (final CSVRecord o : p) {
289                 final String item = o.get(0);
290                 switch (item) {
291                     case "Name of country":
292                         country = o;
293                         break;
294                     case "IBAN prefix country code (ISO 3166)":
295                         cc = o;
296                         break;
297                     case "Country code includes other countries/territories":
298                         additionalCc = o;
299                         break;
300                     case "IBAN structure":
301                         structure = o;
302                         break;
303                     case "IBAN length":
304                         length = o;
305                         break;
306                     default:
307                         break;
308                 }
309             }
310         }
311 
312         assertNotNull(country);
313         assertNotNull(cc);
314         assertNotNull(additionalCc);
315         assertNotNull(structure);
316         assertNotNull(length);
317 
318         final Collection<Arguments> result = new ArrayList<>();
319         for (int i = 1; i < country.size(); i++) {
320             final String ac = additionalCc.get(i);
321             final List<String> aCountry = Arrays.stream(ac.split(","))
322                     .filter(s -> !"N/A".equals(s))
323                     .map(s -> s.replace("(French part)", "")) // special case
324                     .map(String::trim)
325                     .filter(s -> !s.isEmpty())
326                     .collect(Collectors.toList());
327             result.add(Arguments.of(country.get(i), cc.get(i), aCountry, Integer.parseInt(length.get(i)), structure.get(i)));
328         }
329 
330         return result;
331     }
332 
333     static Collection<Arguments> ibanRegistrySourceExamples() throws Exception {
334         final Path ibanRegistry = Paths.get(IBANValidator.class.getResource(IBAN_REGISTRY).toURI());
335 
336         final CSVFormat format = CSVFormat.DEFAULT.builder().setDelimiter('\t').build();
337         final Reader rdr = Files.newBufferedReader(ibanRegistry, IBAN_REGISTRY_CHARSET);
338 
339         CSVRecord country = null;
340         CSVRecord electronicExample = null;
341         CSVRecord lastUpdateDate = null;
342 
343         try (CSVParser p = new CSVParser(rdr, format)) {
344             for (final CSVRecord o : p) {
345                 final String item = o.get(0);
346                 switch (item) {
347                     case "Name of country":
348                         country = o;
349                         break;
350                     case "IBAN electronic format example":
351                         electronicExample = o;
352                         break;
353                     case "Last update date":
354                         lastUpdateDate = o;
355                         break;
356                     default:
357                         break;
358                 }
359             }
360         }
361 
362         assertNotNull(country);
363         final int arraySize = country.size();
364         assertNotNull(electronicExample);
365         assertEquals(arraySize, electronicExample.size());
366         assertNotNull(lastUpdateDate);
367         assertEquals(arraySize, lastUpdateDate.size());
368 
369         final Collection<Arguments> result = new ArrayList<>();
370         Date lastDate = new Date(0);
371         String lastUpdated = null;
372         for (int i = 1; i < country.size(); i++) {
373             result.add(Arguments.of(country.get(i), electronicExample.get(i)));
374             final String mmyy = lastUpdateDate.get(i);
375             final Date dt = DateValidator.getInstance().validate(mmyy, "MMM-yy", Locale.ROOT);
376             if (dt.after(lastDate)) {
377                 lastDate = dt;
378                 lastUpdated = mmyy;
379             }
380         }
381         final long age = (new Date().getTime() - lastDate.getTime()) / MS_PER_DAY;
382         if (age > MAX_AGE_DAYS) { // not necessarily a failure
383             System.out.println("WARNING: expected recent last update date, but found: " + lastUpdated);
384         }
385         return result;
386     }
387 
388     public static Stream<Arguments> validateIbanStatuses() {
389         return Stream.of(
390                 Arguments.of("XX", IBANValidatorStatus.UNKNOWN_COUNTRY),
391                 Arguments.of("AD0101", IBANValidatorStatus.INVALID_LENGTH),
392                 Arguments.of("AD12XX012030200359100100", IBANValidatorStatus.INVALID_PATTERN),
393                 Arguments.of("AD9900012030200359100100", IBANValidatorStatus.INVALID_CHECKSUM),
394                 Arguments.of("AD1200012030200359100100", IBANValidatorStatus.VALID)
395         );
396     }
397 
398     @ParameterizedTest
399     @MethodSource("ibanRegistrySourceExamples")
400     void testExampleAccountsShouldBeValid(final String countryName, final String example) {
401         Assumptions.assumeFalse(INVALID_IBAN_FIXTURES.contains(example), "Skip invalid example: " + example + " for " + countryName);
402         assertTrue(IBANValidator.getInstance().isValid(example), "IBAN validator returned false for " + example + " for " + countryName);
403     }
404 
405     @Test
406     void testGetRegexValidatorPatterns() {
407         assertNotNull(VALIDATOR.getValidator("GB").getRegexValidator().getPatterns(), "GB");
408     }
409 
410     @Test
411     void testGetValidator() {
412         assertNotNull(VALIDATOR.getValidator("GB"), "GB");
413         assertNull(VALIDATOR.getValidator("gb"), "gb");
414     }
415 
416     @Test
417     void testHasValidator() {
418         assertTrue(VALIDATOR.hasValidator("GB"), "GB");
419         assertFalse(VALIDATOR.hasValidator("gb"), "gb");
420     }
421 
422     @ParameterizedTest
423     @FieldSource("INVALID_IBAN_FIXTURES")
424     void testInValid(final String invalidIban) {
425         assertNotNull(INVALID_IBAN_FIXTURES); // ensure field is marked as being used
426         assertFalse(VALIDATOR.isValid(invalidIban), invalidIban);
427     }
428 
429     @ParameterizedTest
430     @FieldSource("VALID_IBAN_FIXTURES")
431     void testMoreValid(final String invalidIban) {
432         assertNotNull(VALID_IBAN_FIXTURES); // ensure field is marked as being used
433         assertTrue(VALIDATOR.isValid(invalidIban), invalidIban);
434     }
435 
436     @Test
437     void testNull() {
438         assertFalse(VALIDATOR.isValid(null), "isValid(null)");
439     }
440 
441     @Test
442     void testSetDefaultValidator1() {
443         final IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> VALIDATOR.setValidator("GB", 15, "GB"));
444         assertEquals("The singleton validator cannot be modified", thrown.getMessage());
445 
446     }
447 
448     @Test
449     void testSetDefaultValidator2() {
450         final IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> VALIDATOR.setValidator("GB", -1, "GB"));
451         assertEquals("The singleton validator cannot be modified", thrown.getMessage());
452     }
453 
454     @Test
455     void testSetValidatorLC() {
456         final IBANValidator validator = new IBANValidator();
457         final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> validator.setValidator("gb", 15, "GB"));
458         assertEquals("Invalid country Code; must be exactly 2 upper-case characters", thrown.getMessage());
459     }
460 
461     @Test
462     void testSetValidatorLen1() {
463         final IBANValidator validator = new IBANValidator();
464         assertNotNull(validator.setValidator("GB", -1, ""), "should be present");
465         assertNull(validator.setValidator("GB", -1, ""), "no longer present");
466     }
467 
468     @Test
469     void testSetValidatorLen35() {
470         final IBANValidator validator = new IBANValidator();
471         final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> validator.setValidator("GB", 35, "GB"));
472         assertEquals("Invalid length parameter, must be in range 8 to 34 inclusive: 35", thrown.getMessage());
473     }
474 
475     @Test
476     void testSetValidatorLen7() {
477         final IBANValidator validator = new IBANValidator();
478         final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> validator.setValidator("GB", 7, "GB"));
479         assertEquals("Invalid length parameter, must be in range 8 to 34 inclusive: 7", thrown.getMessage());
480     }
481 
482     @Test
483     void testSorted() {
484         final IBANValidator validator = new IBANValidator();
485         final Validator[] vals = validator.getDefaultValidators();
486         assertNotNull(vals);
487         for (int i = 1; i < vals.length; i++) {
488             if (vals[i].countryCode.compareTo(vals[i - 1].countryCode) <= 0) {
489                 fail("Not sorted: " + vals[i].countryCode + " <= " + vals[i - 1].countryCode);
490             }
491         }
492     }
493 
494     @ParameterizedTest
495     @FieldSource("VALID_IBAN_FIXTURES")
496     void testValid(final String iban) {
497         assertTrue(IBANCheckDigit.IBAN_CHECK_DIGIT.isValid(iban), "Checksum fail: " + iban);
498         assertTrue(VALIDATOR.hasValidator(iban), "Missing validator: " + iban);
499         assertTrue(VALIDATOR.isValid(iban), iban);
500     }
501 
502     @ParameterizedTest
503     @MethodSource("validateIbanStatuses")
504     void testValidateIbanStatuses(final String iban, final IBANValidatorStatus expectedStatus) {
505         assertEquals(expectedStatus, IBANValidator.getInstance().validate(iban));
506     }
507 
508     @ParameterizedTest
509     @MethodSource("ibanRegistrySource")
510     void testValidatorShouldExistWithProperConfiguration(final String countryName, final String countryCode, final List<String> acountyCode,
511             final int ibanLength, final String structure) throws Exception {
512         final String countryInfo = " countryCode: " + countryCode + ", countryName: " + countryName;
513         final Validator validator = IBANValidator.getInstance().getValidator(countryCode);
514 
515         assertNotNull(validator, "IBAN validator returned null for" + countryInfo);
516         assertEquals(ibanLength, validator.getIbanLength(), "IBAN length should be " + ibanLength + " for" + countryInfo);
517 
518         final List<String> allPatterns = Arrays.stream(validator.getRegexValidator().getPatterns()).map(Pattern::pattern).collect(Collectors.toList());
519 
520         final String re = fmtRE(structure.substring(2), ibanLength - 2); //allow for prefix
521         assertTrue(allPatterns.remove(countryCode + re), "No pattern " + countryCode + re + " found for " + countryInfo);
522         for (final String ac : acountyCode) {
523             assertTrue(allPatterns.remove(ac + re), "No additional country code " + ac + " found for " + countryInfo);
524         }
525         assertTrue(allPatterns.isEmpty(), "Unrecognized patterns: " + allPatterns + " for" + countryInfo);
526     }
527 }