View Javadoc
1   package emissary.test.core.junit5;
2   
3   import emissary.core.DataObjectFactory;
4   import emissary.core.Family;
5   import emissary.core.IBaseDataObject;
6   import emissary.kff.KffDataObjectHandler;
7   import emissary.place.IServiceProviderPlace;
8   import emissary.test.core.junit5.LogbackTester.SimplifiedLogEvent;
9   import emissary.util.io.ResourceReader;
10  import emissary.util.os.OSReleaseUtil;
11  import emissary.util.xml.JDOMUtil;
12  
13  import com.google.errorprone.annotations.ForOverride;
14  import jakarta.annotation.Nullable;
15  import jakarta.xml.bind.DatatypeConverter;
16  import org.apache.commons.collections4.CollectionUtils;
17  import org.apache.commons.io.IOUtils;
18  import org.apache.commons.lang3.StringUtils;
19  import org.jdom2.Attribute;
20  import org.jdom2.DataConversionException;
21  import org.jdom2.Document;
22  import org.jdom2.Element;
23  import org.junit.jupiter.api.AfterEach;
24  import org.junit.jupiter.api.BeforeEach;
25  import org.junit.jupiter.params.ParameterizedTest;
26  import org.junit.jupiter.params.provider.Arguments;
27  import org.junit.jupiter.params.provider.MethodSource;
28  import org.slf4j.Logger;
29  import org.slf4j.LoggerFactory;
30  
31  import java.io.IOException;
32  import java.io.InputStream;
33  import java.nio.charset.StandardCharsets;
34  import java.util.ArrayList;
35  import java.util.Arrays;
36  import java.util.Collections;
37  import java.util.List;
38  import java.util.stream.Stream;
39  
40  import static org.junit.jupiter.api.Assertions.assertEquals;
41  import static org.junit.jupiter.api.Assertions.assertFalse;
42  import static org.junit.jupiter.api.Assertions.assertNotNull;
43  import static org.junit.jupiter.api.Assertions.assertNull;
44  import static org.junit.jupiter.api.Assertions.assertTrue;
45  import static org.junit.jupiter.api.Assertions.fail;
46  
47  public abstract class ExtractionTest extends UnitTest {
48  
49      protected static final Logger logger = LoggerFactory.getLogger(ExtractionTest.class);
50  
51      private static final List<IBaseDataObject> NO_ATTACHMENTS = Collections.emptyList();
52      private static final byte[] INCORRECT_VIEW_MESSAGE = "This is the incorrect view, the place should not have processed this view".getBytes();
53  
54      /**
55       * The list of actual logEvents generated by executing the place.
56       */
57      protected List<SimplifiedLogEvent> actualSimplifiedLogEvents;
58  
59      protected KffDataObjectHandler kff =
60              new KffDataObjectHandler(KffDataObjectHandler.TRUNCATE_KNOWN_DATA, KffDataObjectHandler.SET_FORM_WHEN_KNOWN,
61                      KffDataObjectHandler.SET_FILE_TYPE);
62      @Nullable
63      protected IServiceProviderPlace place = null;
64      @Nullable
65      private static final String SYSTEM_OS_RELEASE;
66      @Nullable
67      private static final String MAJOR_OS_VERSION;
68  
69      static {
70          if (OSReleaseUtil.isUbuntu()) {
71              SYSTEM_OS_RELEASE = "ubuntu";
72              MAJOR_OS_VERSION = OSReleaseUtil.getMajorReleaseVersion();
73          } else if (OSReleaseUtil.isCentOs()) {
74              SYSTEM_OS_RELEASE = "centos";
75              MAJOR_OS_VERSION = OSReleaseUtil.getMajorReleaseVersion();
76          } else if (OSReleaseUtil.isRhel()) {
77              SYSTEM_OS_RELEASE = "rhel";
78              MAJOR_OS_VERSION = OSReleaseUtil.getMajorReleaseVersion();
79          } else if (OSReleaseUtil.isMac()) {
80              SYSTEM_OS_RELEASE = "mac";
81              MAJOR_OS_VERSION = OSReleaseUtil.getMajorReleaseVersion();
82          } else {
83              SYSTEM_OS_RELEASE = null;
84              MAJOR_OS_VERSION = null;
85          }
86      }
87  
88      @BeforeEach
89      public void setUpPlace() throws Exception {
90          place = createPlace();
91      }
92  
93      @AfterEach
94      public void tearDownPlace() {
95          if (place != null) {
96              place.shutDown();
97              place = null;
98          }
99      }
100 
101     /**
102      * Derived classes must implement this
103      */
104     public abstract IServiceProviderPlace createPlace() throws IOException;
105 
106     public static Stream<? extends Arguments> data() {
107         return getMyTestParameterFiles(ExtractionTest.class);
108     }
109 
110     /**
111      * Allow overriding the initial form in extensions to this test.
112      *
113      * By default, get the initial form from the filename in the form {@code INITIAL_FORM@2.dat} where {@code INITIAL_FORM}
114      * will be the initial form.
115      *
116      * @param resource to get the form from
117      * @return the initial form
118      */
119     @ForOverride
120     protected String getInitialForm(final String resource) {
121         return resource.replaceAll("^.*/([^/@]+)(@\\d+)?\\.dat$", "$1");
122     }
123 
124     /**
125      * This method returns the logger name to capture log events from or null if log events are not to be checked.
126      *
127      * @return the logger name to capture log events from or null (the default) if log events are not to be checked.
128      */
129     protected String getLogbackLoggerName() {
130         return null;
131     }
132 
133     @ParameterizedTest
134     @MethodSource("data")
135     public void testExtractionPlace(String resource) {
136         logger.debug("Running {} test on resource {}", place.getClass().getName(), resource);
137 
138         // Need a pair consisting of a .dat file and a .xml file (answers)
139         Document controlDoc = getAnswerDocumentFor(resource);
140         if (controlDoc == null) {
141             fail("No answers provided for test " + resource);
142         }
143 
144         try (InputStream doc = new ResourceReader().getResourceAsStream(resource)) {
145             byte[] data = IOUtils.toByteArray(doc);
146             String initialForm = getInitialForm(resource);
147             IBaseDataObject payload = DataObjectFactory.getInstance(data, resource, initialForm);
148             setupPayload(payload, controlDoc);
149             processPreHook(payload, controlDoc);
150             List<IBaseDataObject> attachments = processHeavyDutyHook(place, payload);
151             processPostHook(payload, attachments);
152             checkAnswersPreHook(controlDoc, payload, attachments, resource);
153             checkAnswers(controlDoc, payload, attachments, resource);
154             checkAnswersPostHook(controlDoc, payload, attachments, resource);
155         } catch (Exception ex) {
156             logger.error("Error running test {}", resource, ex);
157             fail("Cannot run test " + resource, ex);
158         }
159     }
160 
161     protected void processPreHook(IBaseDataObject payload, Document controlDoc) {
162         // Nothing to do here
163     }
164 
165     protected void processPostHook(IBaseDataObject payload, List<IBaseDataObject> attachments) {
166         // Nothing to do here
167     }
168 
169     protected List<IBaseDataObject> processHeavyDutyHook(IServiceProviderPlace place, IBaseDataObject payload) throws Exception {
170         if (getLogbackLoggerName() == null) {
171             actualSimplifiedLogEvents = new ArrayList<>();
172             return place.agentProcessHeavyDuty(payload);
173         } else {
174             try (LogbackTester logbackTester = new LogbackTester(getLogbackLoggerName())) {
175                 final List<IBaseDataObject> attachments = place.agentProcessHeavyDuty(payload);
176                 actualSimplifiedLogEvents = logbackTester.getSimplifiedLogEvents();
177                 return attachments;
178             }
179         }
180     }
181 
182     protected void checkAnswersPreHook(Document answers, IBaseDataObject payload, List<IBaseDataObject> attachments, String tname) {
183         // Nothing to do here
184     }
185 
186     protected void checkAnswersPreHook(Element answers, IBaseDataObject payload, IBaseDataObject attachment, String tname) {
187         // Nothing to do here
188     }
189 
190     /**
191      * This method allows log events to be modified prior to checkAnswers being called.
192      *
193      * In the default case, do nothing.
194      *
195      * @param simplifiedLogEvents the log events to be tweaked.
196      */
197     protected void checkAnswersPreHookLogEvents(List<SimplifiedLogEvent> simplifiedLogEvents) {
198         // No-op unless overridden
199     }
200 
201     protected void checkAnswersPostHook(Document answers, IBaseDataObject payload, List<IBaseDataObject> attachments, String tname) {
202         // Nothing to do here
203     }
204 
205     protected void checkAnswersPostHook(Element answers, IBaseDataObject payload, IBaseDataObject attachment, String tname) {
206         // Nothing to do here
207     }
208 
209     protected void checkAnswers(Document answers, IBaseDataObject payload, List<IBaseDataObject> attachments, String tname)
210             throws DataConversionException {
211         Element root = answers.getRootElement();
212         Element parent = root.getChild("answers");
213         if (parent == null) {
214             parent = root;
215         }
216 
217         // Check the payload
218         checkAnswers(parent, payload, attachments, tname);
219 
220         // Check each attachment
221         for (int attNum = 1; attNum <= attachments.size(); attNum++) {
222             String atname = tname + Family.SEP + attNum;
223             Element el = parent.getChild("att" + attNum);
224             if (el != null) {
225                 checkAnswersPreHook(el, payload, attachments.get(attNum - 1), atname);
226                 checkAnswers(el, attachments.get(attNum - 1), null, atname);
227                 checkAnswersPostHook(el, payload, attachments.get(attNum - 1), atname);
228             }
229         }
230     }
231 
232     protected void checkAnswers(Element el, IBaseDataObject payload, @Nullable List<IBaseDataObject> attachments, String tname)
233             throws DataConversionException {
234 
235         int numAtt = JDOMUtil.getChildIntValue(el, "numAttachments");
236         long numAttElements = el.getChildren().stream().filter(c -> c.getName().startsWith("att")).count();
237         // check attachments answer file count against payload count
238         if (numAtt > -1) {
239             assertEquals(numAtt, attachments != null ? attachments.size() : 0,
240                     String.format("Expected <numAttachments> in %s not equal to number of att in payload.", tname));
241         } else if (numAtt == -1 && numAttElements > 0) {
242             assertEquals(numAttElements, attachments != null ? attachments.size() : 0,
243                     String.format("Expected <att#> in %s not equal to number of att in payload.", tname));
244         } else {
245             if (attachments != null && !attachments.isEmpty()) {
246                 fail(String.format("%d attachments in payload with no count in answer xml, add matching <numAttachments> count for %s",
247                         attachments.size(), tname));
248             }
249         }
250 
251         for (Element currentForm : el.getChildren("currentForm")) {
252             String cf = currentForm.getTextTrim();
253             if (cf != null) {
254                 Attribute index = currentForm.getAttribute("index");
255                 if (index != null) {
256                     assertEquals(payload.currentFormAt(index.getIntValue()), cf,
257                             String.format("Current form '%s' not found at position [%d] in %s, %s", cf, index.getIntValue(), tname,
258                                     payload.getAllCurrentForms()));
259                 } else {
260                     assertTrue(payload.searchCurrentForm(cf) > -1,
261                             String.format("Current form %s not found in %s, %s", cf, tname, payload.getAllCurrentForms()));
262                 }
263             }
264         }
265 
266         String cf = el.getChildTextTrim("currentForm");
267         if (cf != null) {
268             assertTrue(payload.searchCurrentForm(cf) > -1,
269                     String.format("Current form '%s' not found in %s, %s", cf, tname, payload.getAllCurrentForms()));
270         }
271 
272         String ft = el.getChildTextTrim("fileType");
273         if (ft != null) {
274             assertEquals(ft, payload.getFileType(), String.format("Expected File Type '%s' in %s", ft, tname));
275         }
276 
277         int cfsize = JDOMUtil.getChildIntValue(el, "currentFormSize");
278         if (cfsize > -1) {
279             assertEquals(cfsize, payload.currentFormSize(), "Current form size in " + tname);
280         }
281 
282         String classification = el.getChildTextTrim("classification");
283         if (classification != null) {
284             assertEquals(classification, payload.getClassification(),
285                     String.format("Classification in '%s' is '%s', not expected '%s'", tname, payload.getClassification(), classification));
286         }
287 
288         for (Element dataLength : el.getChildren("dataLength")) {
289             if (verifyOs(dataLength)) {
290                 int length;
291                 try {
292                     length = Integer.parseInt(dataLength.getValue());
293                 } catch (NumberFormatException e) {
294                     length = -1;
295                 }
296                 if (length > -1) {
297                     assertEquals(length, payload.dataLength(), "Data length in " + tname);
298                 }
299             }
300         }
301 
302         String shortName = el.getChildTextTrim("shortName");
303         if (shortName != null && shortName.length() > 0) {
304             assertEquals(shortName, payload.shortName(), "Shortname does not match expected in " + tname);
305         }
306 
307         String fontEncoding = el.getChildTextTrim("fontEncoding");
308         if (StringUtils.isNotBlank(fontEncoding)) {
309             assertEquals(fontEncoding, payload.getFontEncoding(), "Font encoding does not match expected in " + tname);
310         }
311 
312         String broke = el.getChildTextTrim("broken");
313         if (broke != null && broke.length() > 0) {
314             assertEquals(broke, payload.isBroken() ? "true" : "false", "Broken status in " + tname);
315         }
316 
317         String procError = el.getChildTextTrim("procError");
318         if (procError != null && !procError.isEmpty()) {
319             assertNotNull(payload.getProcessingError(),
320                     String.format("Expected processing error '%s' in %s", procError, tname));
321             // simple work around for answer files, so we can see multiple errors w/o dealing with line breaks added on by
322             // StringBuilder in BDO
323             String shortProcErrMessage = payload.getProcessingError().replaceAll("\n", ";");
324             assertEquals(procError, shortProcErrMessage, "Processing Error does not match expected in " + tname);
325         }
326 
327         // Check specified metadata
328         for (Element meta : el.getChildren("meta")) {
329             if (verifyOs(meta)) {
330                 String key = meta.getChildTextTrim("name");
331                 checkForMissingNameElement("meta", key, tname);
332                 checkStringValue(meta, payload.getStringParameter(key), tname);
333             }
334         }
335 
336         // Check specified nometa
337         for (Element meta : el.getChildren("nometa")) {
338             if (verifyOs(meta)) {
339                 String key = meta.getChildTextTrim("name");
340                 checkForMissingNameElement("nometa", key, tname);
341                 assertFalse(payload.hasParameter(key),
342                         String.format("Metadata element '%s' in '%s' should not exist, but has value of '%s'", key, tname,
343                                 payload.getStringParameter(key)));
344             }
345         }
346 
347         // Check the primary view. Even though there is only one
348         // primary view there can be multiple elements to test it
349         // with differing matchMode operators
350         for (Element dataEl : el.getChildren("data")) {
351             if (verifyOs(dataEl)) {
352                 byte[] payloadData = payload.data();
353                 checkStringValue(dataEl, new String(payloadData), tname);
354             }
355         }
356 
357         // Check each alternate view
358         for (Element view : el.getChildren("view")) {
359             if (verifyOs(view)) {
360                 String viewName = view.getChildTextTrim("name");
361                 String lengthStr = view.getChildTextTrim("length");
362                 byte[] viewData = payload.getAlternateView(viewName);
363                 assertNotNull(viewData, String.format("Alternate View '%s' is missing in %s", viewName, tname));
364                 if (lengthStr != null) {
365                     assertEquals(Integer.parseInt(lengthStr), viewData.length,
366                             String.format("Length of Alternate View '%s' is wrong in %s", viewName, tname));
367                 }
368                 checkStringValue(view, new String(viewData), tname);
369             }
370         }
371 
372         // Check for noview items
373         for (Element view : el.getChildren("noview")) {
374             if (verifyOs(view)) {
375                 String viewName = view.getChildTextTrim("name");
376                 byte[] viewData = payload.getAlternateView(viewName);
377                 assertNull(viewData, String.format("Alternate View '%s' is present, but should not be, in %s", viewName, tname));
378             }
379         }
380 
381         // Check each extract
382         int extractCount = JDOMUtil.getChildIntValue(el, "extractCount");
383         long numExtractElements =
384                 el.getChildren().stream().filter(c -> c.getName().startsWith("extract") && !c.getName().startsWith("extractCount")).count();
385         if (payload.hasExtractedRecords()) {
386             List<IBaseDataObject> extractedChildren = payload.getExtractedRecords();
387             int foundCount = extractedChildren.size();
388             // check extracted records answer file count against payload count
389             if (extractCount > -1) {
390                 assertEquals(extractCount, foundCount,
391                         String.format("Expected <extractCount> in %s not equal to number of extracts in payload.", tname));
392             } else if (extractCount == -1 && numExtractElements > 0) {
393                 assertEquals(numExtractElements, foundCount,
394                         String.format("Expected <extract#> in %s not equal to number of extracts in payload.", tname));
395             } else {
396                 fail(String.format("%d extracts in payload with no count in answer xml, add matching <extractCount> count for %s",
397                         foundCount, tname));
398             }
399 
400             int attNum = 1;
401             for (IBaseDataObject extractedChild : extractedChildren) {
402                 Element extel = el.getChild("extract" + attNum);
403                 if (extel != null) {
404                     checkAnswers(extel, extractedChild, NO_ATTACHMENTS, String.format("%s::extract%d", tname, attNum));
405                 }
406                 attNum++;
407             }
408         } else {
409             if (extractCount > -1) {
410                 assertEquals(0, extractCount,
411                         String.format("No extracted children in '%s' when <extractCount> is %d", tname, extractCount));
412             } else if (numExtractElements > 0) {
413                 assertEquals(0, numExtractElements,
414                         String.format("No extracted children in '%s' when <extract#> is %d", tname, numExtractElements));
415             }
416         }
417     }
418 
419     private static void checkForMissingNameElement(String parentTag, String key, String tname) {
420         if (key == null) {
421             fail(String.format("The element %s has a problem in %s: does not have a child name element", parentTag, tname));
422         }
423     }
424 
425     protected void checkStringValue(Element meta, String data, String tname) {
426         String key = meta.getChildTextTrim("name");
427         String value = meta.getChildText("value");
428         String matchMode = "equals";
429         Attribute mm = meta.getAttribute("matchMode");
430 
431         if (value == null) {
432             return; // checking the value is optional
433         }
434 
435         if (mm != null) {
436             matchMode = mm.getValue();
437         }
438 
439         if (matchMode.equals("equals")) {
440             assertEquals(value, data,
441                     String.format("%s element '%s' problem in %s value '%s' does not equal '%s'", meta.getName(), key, tname, data, value));
442         } else if (matchMode.equals("index") || matchMode.equals("contains")) {
443             assertTrue(data.contains(value),
444                     String.format("%s element '%s' problem in %s value '%s' does not index '%s'", meta.getName(), key, tname, data, value));
445         } else if (matchMode.equals("!index") || matchMode.equals("!contains")) {
446             assertFalse(data.contains(value),
447                     String.format("%s element '%s' problem in %s value '%s' should not be indexed in '%s'", meta.getName(), key, tname, value, data));
448         } else if (matchMode.equals("match")) {
449             assertTrue(data.matches(value),
450                     String.format("%s element '%s' problem in %s value '%s' does not match '%s'", meta.getName(), key, tname, data, value));
451         } else if (matchMode.equals("base64")) {
452             // decode value as a base64 encoded byte[] array and use the string
453             // representation of the byte array for comparison to the incoming value
454             value = new String(DatatypeConverter.parseBase64Binary(value));
455             assertEquals(value, data,
456                     String.format("%s element '%s' problem in %s value '%s' does not match '%s'", meta.getName(), key, tname, data, value));
457         } else if ("collection".equalsIgnoreCase(matchMode)) {
458             Attribute separatorAttribute = meta.getAttribute("collectionSeparator");
459             String separator = null != separatorAttribute ? separatorAttribute.getValue() : ","; // comma is default
460             // separator
461             List<String> expectedValues = Arrays.asList(value.split(separator));
462             List<String> actualValues = Arrays.asList(data.split(separator));
463             assertTrue(CollectionUtils.isEqualCollection(expectedValues, actualValues),
464                     String.format(
465                             "%s element '%s' problem in %s did not have equal collection, value '%s' does not equal '%s' split by separator '%s'",
466                             meta.getName(), key, tname, data, value, separator));
467 
468         } else {
469             fail(String.format("Problematic matchMode specified for test '%s' on %s in element %s", matchMode, key, meta.getName()));
470         }
471     }
472 
473     protected boolean verifyOs(Element element) {
474         Attribute specifiedOs = element.getAttribute("os-release");
475         Attribute specifiedVersion = element.getAttribute("os-version");
476         if (specifiedOs != null) {
477             String os = specifiedOs.getValue();
478             switch (os) {
479                 case "ubuntu":
480                 case "centos":
481                 case "rhel":
482                 case "mac":
483                     if (specifiedVersion != null) {
484                         return os.equals(SYSTEM_OS_RELEASE) && specifiedVersion.getValue().equals(MAJOR_OS_VERSION);
485                     } else {
486                         // verify os matches, major os version not specified
487                         return os.equals(SYSTEM_OS_RELEASE);
488                     }
489                 default:
490                     fail("specified OS needs to match ubuntu, centos, rhel, or mac. Provided OS=" + os);
491             }
492         }
493         // os-release is not set as an attribute, element applicable for all os
494         return true;
495     }
496 
497     protected void setupPayload(IBaseDataObject payload, Document doc) {
498         kff.hash(payload);
499         Element root = doc.getRootElement();
500         Element setup = root.getChild("setup");
501         boolean didSetFiletype = false;
502         if (setup != null) {
503             List<Element> cfChildren = setup.getChildren("initialForm");
504             if (!cfChildren.isEmpty()) {
505                 payload.popCurrentForm(); // remove default
506             }
507             for (Element cf : cfChildren) {
508                 payload.enqueueCurrentForm(cf.getTextTrim());
509             }
510 
511             final String classification = setup.getChildTextTrim("classification");
512             if (StringUtils.isNotBlank(classification)) {
513                 payload.setClassification(classification);
514             }
515 
516             final String fontEncoding = setup.getChildTextTrim("fontEncoding");
517             if (StringUtils.isNotBlank(fontEncoding)) {
518                 payload.setFontEncoding(fontEncoding);
519             }
520 
521             for (Element meta : setup.getChildren("meta")) {
522                 String key = meta.getChildTextTrim("name");
523                 String value = meta.getChildTextTrim("value");
524                 payload.appendParameter(key, value);
525             }
526 
527             for (Element altView : setup.getChildren("altView")) {
528                 String name = altView.getChildTextTrim("name");
529                 byte[] value = altView.getChildText("value").getBytes(StandardCharsets.UTF_8);
530                 payload.addAlternateView(name, value);
531             }
532 
533             final String fileType = setup.getChildTextTrim("fileType");
534             if (StringUtils.isNotBlank(fileType)) {
535                 payload.setFileType(fileType);
536                 didSetFiletype = true;
537             }
538 
539             final String inputAlternateView = setup.getChildTextTrim("inputAlternateView");
540             if (StringUtils.isNotBlank(inputAlternateView)) {
541                 final byte[] data = payload.data();
542                 payload.addAlternateView(inputAlternateView, data);
543                 payload.setData(INCORRECT_VIEW_MESSAGE);
544             }
545 
546             final String badAlternateView = setup.getChildTextTrim("badAlternateView");
547             if (StringUtils.isNotBlank(badAlternateView)) {
548                 payload.addAlternateView(badAlternateView, INCORRECT_VIEW_MESSAGE);
549             }
550         }
551         if (!didSetFiletype) {
552             payload.setFileType(payload.currentForm());
553         }
554     }
555 }