001package org.hl7.fhir.r4.test.utils;
002
003/*
004  Copyright (c) 2011+, HL7, Inc.
005  All rights reserved.
006  
007  Redistribution and use in source and binary forms, with or without modification, 
008  are permitted provided that the following conditions are met:
009    
010   * Redistributions of source code must retain the above copyright notice, this 
011     list of conditions and the following disclaimer.
012   * Redistributions in binary form must reproduce the above copyright notice, 
013     this list of conditions and the following disclaimer in the documentation 
014     and/or other materials provided with the distribution.
015   * Neither the name of HL7 nor the names of its contributors may be used to 
016     endorse or promote products derived from this software without specific 
017     prior written permission.
018  
019  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
020  ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 
021  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 
022  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 
023  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 
024  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
025  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
026  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
027  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
028  POSSIBILITY OF SUCH DAMAGE.
029  
030 */
031
032import java.io.File;
033import java.io.FileInputStream;
034import java.io.FileNotFoundException;
035import java.io.IOException;
036import java.io.InputStream;
037import java.nio.file.Path;
038import java.nio.file.Paths;
039import java.util.ArrayList;
040import java.util.List;
041import java.util.Locale;
042import java.util.Map;
043
044import javax.xml.parsers.DocumentBuilder;
045import javax.xml.parsers.DocumentBuilderFactory;
046
047import org.apache.commons.codec.binary.Base64;
048import org.apache.commons.io.IOUtils;
049import org.fhir.ucum.UcumEssenceService;
050import org.hl7.fhir.r4.context.IWorkerContext;
051import org.hl7.fhir.r4.context.SimpleWorkerContext;
052import org.hl7.fhir.r4.model.Parameters;
053import org.hl7.fhir.utilities.FileUtilities;
054import org.hl7.fhir.utilities.MarkedToMoveToAdjunctPackage;
055import org.hl7.fhir.utilities.Utilities;
056import org.hl7.fhir.utilities.filesystem.CSFile;
057import org.hl7.fhir.utilities.filesystem.ManagedFileAccess;
058import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager;
059import org.hl7.fhir.utilities.settings.FhirSettings;
060import org.hl7.fhir.utilities.tests.BaseTestingUtilities;
061import org.hl7.fhir.utilities.tests.ResourceLoaderTests;
062import org.hl7.fhir.utilities.tests.TestConfig;
063import org.hl7.fhir.utilities.xml.XMLUtil;
064import org.w3c.dom.Document;
065import org.w3c.dom.Element;
066import org.w3c.dom.NamedNodeMap;
067import org.w3c.dom.Node;
068
069import com.google.gson.JsonArray;
070import com.google.gson.JsonElement;
071import com.google.gson.JsonNull;
072import com.google.gson.JsonObject;
073import com.google.gson.JsonPrimitive;
074import com.google.gson.JsonSyntaxException;
075
076@MarkedToMoveToAdjunctPackage
077public class TestingUtilities {
078  private static final boolean SHOW_DIFF = false;
079
080  static public IWorkerContext fcontext;
081
082  public static IWorkerContext context() {
083    if (fcontext == null) {
084      FilesystemPackageCacheManager pcm;
085      try {
086        pcm = new FilesystemPackageCacheManager.Builder().build();
087        fcontext = SimpleWorkerContext.fromPackage(pcm.loadPackage("hl7.fhir.r4.core", "4.0.1"));
088        fcontext
089            .setUcumService(new UcumEssenceService(TestingUtilities.resourceNameToFile("ucum", "ucum-essence.xml")));
090        fcontext.setExpansionProfile(new Parameters());
091      } catch (Exception e) {
092        throw new Error(e);
093      }
094
095    }
096    return fcontext;
097  }
098
099  static public boolean silent;
100
101  static public String fixedpath;
102  static public String contentpath;
103
104  public static String home() throws IOException {
105    if (fixedpath != null)
106      return fixedpath;
107    String s = System.getenv("FHIR_HOME");
108    if (!Utilities.noString(s))
109      return s;
110    s = "C:\\work\\org.hl7.fhir\\build";
111    if (ManagedFileAccess.file(s).exists())
112      return s;
113    throw new Error("FHIR Home directory not configured");
114  }
115
116  public static String content() throws IOException {
117    if (contentpath != null)
118      return contentpath;
119    String s = "R:\\fhir\\publish";
120    if (ManagedFileAccess.file(s).exists())
121      return s;
122    return Utilities.path(home(), "publish");
123  }
124
125  // diretory that contains all the US implementation guides
126  public static String us() throws IOException {
127    if (fixedpath != null)
128      return fixedpath;
129    String s = System.getenv("FHIR_HOME");
130    if (!Utilities.noString(s))
131      return s;
132    s = "C:\\work\\org.hl7.fhir.us";
133    if (ManagedFileAccess.file(s).exists())
134      return s;
135    throw new Error("FHIR US directory not configured");
136  }
137
138  public static String checkXMLIsSame(InputStream f1, InputStream f2) throws Exception {
139    String result = compareXml(f1, f2);
140    return result;
141  }
142
143  public static String checkXMLIsSame(String f1, String f2) throws Exception {
144    String result = compareXml(f1, f2);
145    if (result != null && SHOW_DIFF) {
146      String diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
147      List<String> command = new ArrayList<String>();
148      command.add("\"" + diff + "\" \"" + f1 + "\" \"" + f2 + "\"");
149
150      ProcessBuilder builder = new ProcessBuilder(command);
151      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
152      builder.start();
153
154    }
155    return result;
156  }
157
158  private static String compareXml(InputStream f1, InputStream f2) throws Exception {
159    return compareElements("", loadXml(f1).getDocumentElement(), loadXml(f2).getDocumentElement());
160  }
161
162  private static String compareXml(String f1, String f2) throws Exception {
163    return compareElements("", loadXml(f1).getDocumentElement(), loadXml(f2).getDocumentElement());
164  }
165
166  private static String compareElements(String path, Element e1, Element e2) {
167    if (!e1.getNamespaceURI().equals(e2.getNamespaceURI()))
168      return "Namespaces differ at " + path + ": " + e1.getNamespaceURI() + "/" + e2.getNamespaceURI();
169    if (!e1.getLocalName().equals(e2.getLocalName()))
170      return "Names differ at " + path + ": " + e1.getLocalName() + "/" + e2.getLocalName();
171    path = path + "/" + e1.getLocalName();
172    String s = compareAttributes(path, e1.getAttributes(), e2.getAttributes());
173    if (!Utilities.noString(s))
174      return s;
175    s = compareAttributes(path, e2.getAttributes(), e1.getAttributes());
176    if (!Utilities.noString(s))
177      return s;
178
179    Node c1 = e1.getFirstChild();
180    Node c2 = e2.getFirstChild();
181    c1 = skipBlankText(c1);
182    c2 = skipBlankText(c2);
183    while (c1 != null && c2 != null) {
184      if (c1.getNodeType() != c2.getNodeType())
185        return "node type mismatch in children of " + path + ": " + Integer.toString(e1.getNodeType()) + "/"
186            + Integer.toString(e2.getNodeType());
187      if (c1.getNodeType() == Node.TEXT_NODE) {
188        if (!normalise(c1.getTextContent()).equals(normalise(c2.getTextContent())))
189          return "Text differs at " + path + ": " + normalise(c1.getTextContent()) + "/"
190              + normalise(c2.getTextContent());
191      } else if (c1.getNodeType() == Node.ELEMENT_NODE) {
192        s = compareElements(path, (Element) c1, (Element) c2);
193        if (!Utilities.noString(s))
194          return s;
195      }
196
197      c1 = skipBlankText(c1.getNextSibling());
198      c2 = skipBlankText(c2.getNextSibling());
199    }
200    if (c1 != null)
201      return "node mismatch - more nodes in source in children of " + path;
202    if (c2 != null)
203      return "node mismatch - more nodes in target in children of " + path;
204    return null;
205  }
206
207  private static Object normalise(String text) {
208    String result = text.trim().replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
209    while (result.contains("  "))
210      result = result.replace("  ", " ");
211    return result;
212  }
213
214  private static String compareAttributes(String path, NamedNodeMap src, NamedNodeMap tgt) {
215    for (int i = 0; i < src.getLength(); i++) {
216
217      Node sa = src.item(i);
218      String sn = sa.getNodeName();
219      if (!(sn.equals("xmlns") || sn.startsWith("xmlns:"))) {
220        Node ta = tgt.getNamedItem(sn);
221        if (ta == null)
222          return "Attributes differ at " + path + ": missing attribute " + sn;
223        if (!normalise(sa.getTextContent()).equals(normalise(ta.getTextContent()))) {
224          byte[] b1 = unBase64(sa.getTextContent());
225          byte[] b2 = unBase64(ta.getTextContent());
226          if (!sameBytes(b1, b2))
227            return "Attributes differ at " + path + ": value " + normalise(sa.getTextContent()) + "/"
228                + normalise(ta.getTextContent());
229        }
230      }
231    }
232    return null;
233  }
234
235  private static boolean sameBytes(byte[] b1, byte[] b2) {
236    if (b1.length == 0 || b2.length == 0)
237      return false;
238    if (b1.length != b2.length)
239      return false;
240    for (int i = 0; i < b1.length; i++)
241      if (b1[i] != b2[i])
242        return false;
243    return true;
244  }
245
246  private static byte[] unBase64(String text) {
247    return Base64.decodeBase64(text);
248  }
249
250  private static Node skipBlankText(Node node) {
251    while (node != null && (((node.getNodeType() == Node.TEXT_NODE) && Utilities.isAllWhitespace(node.getTextContent()))
252        || (node.getNodeType() == Node.COMMENT_NODE)))
253      node = node.getNextSibling();
254    return node;
255  }
256
257  private static Document loadXml(String fn) throws Exception {
258    return loadXml(ManagedFileAccess.inStream(fn));
259  }
260
261  private static Document loadXml(InputStream fn) throws Exception {
262    DocumentBuilderFactory factory = XMLUtil.newXXEProtectedDocumentBuilderFactory();
263    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
264    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
265    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
266    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
267    factory.setXIncludeAware(false);
268    factory.setExpandEntityReferences(false);
269
270    factory.setNamespaceAware(true);
271    DocumentBuilder builder = factory.newDocumentBuilder();
272    return builder.parse(fn);
273  }
274
275  public static String checkJsonSrcIsSame(String s1, String s2)
276      throws JsonSyntaxException, FileNotFoundException, IOException {
277    return checkJsonSrcIsSame(s1, s2, true);
278  }
279
280  public static String checkJsonSrcIsSame(String s1, String s2, boolean showDiff)
281      throws JsonSyntaxException, FileNotFoundException, IOException {
282    String result = compareJsonSrc(s1, s2);
283    if (result != null && SHOW_DIFF && showDiff) {
284      String diff = null;
285      if (System.getProperty("os.name").contains("Linux"))
286        diff = Utilities.path("/", "usr", "bin", "meld");
287      else {
288        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"),
289            "\\WinMergeU.exe", null))
290          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
291        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"),
292            "\\Meld.exe", null))
293          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
294      }
295      if (diff == null || diff.isEmpty())
296        return result;
297
298      List<String> command = new ArrayList<String>();
299      String f1 = Utilities.path("[tmp]", "input" + s1.hashCode() + ".json");
300      String f2 = Utilities.path("[tmp]", "output" + s2.hashCode() + ".json");
301      FileUtilities.stringToFile(s1, f1);
302      FileUtilities.stringToFile(s2, f2);
303      command.add(diff);
304      if (diff.toLowerCase().contains("meld"))
305        command.add("--newtab");
306      command.add(f1);
307      command.add(f2);
308
309      ProcessBuilder builder = new ProcessBuilder(command);
310      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
311      builder.start();
312
313    }
314    return result;
315  }
316
317  public static String checkJsonIsSame(String f1, String f2)
318      throws JsonSyntaxException, FileNotFoundException, IOException {
319    String result = compareJson(f1, f2);
320    if (result != null && SHOW_DIFF) {
321      String diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
322      List<String> command = new ArrayList<String>();
323      command.add("\"" + diff + "\" \"" + f1 + "\" \"" + f2 + "\"");
324
325      ProcessBuilder builder = new ProcessBuilder(command);
326      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
327      builder.start();
328
329    }
330    return result;
331  }
332
333  private static String compareJsonSrc(String f1, String f2)
334      throws JsonSyntaxException, FileNotFoundException, IOException {
335    JsonObject o1 = (JsonObject) new com.google.gson.JsonParser().parse(f1);
336    JsonObject o2 = (JsonObject) new com.google.gson.JsonParser().parse(f2);
337    return compareObjects("", o1, o2);
338  }
339
340  private static String compareJson(String f1, String f2)
341      throws JsonSyntaxException, FileNotFoundException, IOException {
342    JsonObject o1 = (JsonObject) new com.google.gson.JsonParser().parse(FileUtilities.fileToString(f1));
343    JsonObject o2 = (JsonObject) new com.google.gson.JsonParser().parse(FileUtilities.fileToString(f2));
344    return compareObjects("", o1, o2);
345  }
346
347  private static String compareObjects(String path, JsonObject o1, JsonObject o2) {
348    for (Map.Entry<String, JsonElement> en : o1.entrySet()) {
349      String n = en.getKey();
350      if (!n.equals("fhir_comments")) {
351        if (o2.has(n)) {
352          String s = compareNodes(path + '.' + n, en.getValue(), o2.get(n));
353          if (!Utilities.noString(s))
354            return s;
355        } else
356          return "properties differ at " + path + ": missing property " + n;
357      }
358    }
359    for (Map.Entry<String, JsonElement> en : o2.entrySet()) {
360      String n = en.getKey();
361      if (!n.equals("fhir_comments")) {
362        if (!o1.has(n))
363          return "properties differ at " + path + ": missing property " + n;
364      }
365    }
366    return null;
367  }
368
369  private static String compareNodes(String path, JsonElement n1, JsonElement n2) {
370    if (n1.getClass() != n2.getClass())
371      return "properties differ at " + path + ": type " + n1.getClass().getName() + "/" + n2.getClass().getName();
372    else if (n1 instanceof JsonPrimitive) {
373      JsonPrimitive p1 = (JsonPrimitive) n1;
374      JsonPrimitive p2 = (JsonPrimitive) n2;
375      if (p1.isBoolean() && p2.isBoolean()) {
376        if (p1.getAsBoolean() != p2.getAsBoolean())
377          return "boolean property values differ at " + path + ": type " + p1.getAsString() + "/" + p2.getAsString();
378      } else if (p1.isString() && p2.isString()) {
379        String s1 = p1.getAsString();
380        String s2 = p2.getAsString();
381        if (!(s1.contains("<div") && s2.contains("<div")))
382          if (!s1.equals(s2))
383            if (!sameBytes(unBase64(s1), unBase64(s2)))
384              return "string property values differ at " + path + ": type " + s1 + "/" + s2;
385      } else if (p1.isNumber() && p2.isNumber()) {
386        if (!p1.getAsString().equals(p2.getAsString()))
387          return "number property values differ at " + path + ": type " + p1.getAsString() + "/" + p2.getAsString();
388      } else
389        return "property types differ at " + path + ": type " + p1.getAsString() + "/" + p2.getAsString();
390    } else if (n1 instanceof JsonObject) {
391      String s = compareObjects(path, (JsonObject) n1, (JsonObject) n2);
392      if (!Utilities.noString(s))
393        return s;
394    } else if (n1 instanceof JsonArray) {
395      JsonArray a1 = (JsonArray) n1;
396      JsonArray a2 = (JsonArray) n2;
397
398      if (a1.size() != a2.size())
399        return "array properties differ at " + path + ": count " + Integer.toString(a1.size()) + "/"
400            + Integer.toString(a2.size());
401      for (int i = 0; i < a1.size(); i++) {
402        String s = compareNodes(path + "[" + Integer.toString(i) + "]", a1.get(i), a2.get(i));
403        if (!Utilities.noString(s))
404          return s;
405      }
406    } else if (n1 instanceof JsonNull) {
407
408    } else
409      return "unhandled property " + n1.getClass().getName();
410    return null;
411  }
412
413  public static String checkTextIsSame(String s1, String s2)
414      throws JsonSyntaxException, FileNotFoundException, IOException {
415    return checkTextIsSame(s1, s2, true);
416  }
417
418  public static String checkTextIsSame(String s1, String s2, boolean showDiff)
419      throws JsonSyntaxException, FileNotFoundException, IOException {
420    String result = compareText(s1, s2);
421    if (result != null && SHOW_DIFF && showDiff) {
422      String diff = null;
423      if (System.getProperty("os.name").contains("Linux"))
424        diff = Utilities.path("/", "usr", "bin", "meld");
425      else {
426        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"),
427            "\\WinMergeU.exe", null))
428          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
429        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"),
430            "\\Meld.exe", null))
431          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
432      }
433      if (diff == null || diff.isEmpty())
434        return result;
435
436      List<String> command = new ArrayList<String>();
437      String f1 = Utilities.path("[tmp]", "input" + s1.hashCode() + ".json");
438      String f2 = Utilities.path("[tmp]", "output" + s2.hashCode() + ".json");
439      FileUtilities.stringToFile(s1, f1);
440      FileUtilities.stringToFile(s2, f2);
441      command.add(diff);
442      if (diff.toLowerCase().contains("meld"))
443        command.add("--newtab");
444      command.add(f1);
445      command.add(f2);
446
447      ProcessBuilder builder = new ProcessBuilder(command);
448      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
449      builder.start();
450
451    }
452    return result;
453  }
454
455  private static String compareText(String s1, String s2) {
456    for (int i = 0; i < Integer.min(s1.length(), s2.length()); i++) {
457      if (s1.charAt(i) != s2.charAt(i))
458        return "Strings differ at character " + Integer.toString(i) + ": '" + s1.charAt(i) + "' vs '" + s2.charAt(i)
459            + "'";
460    }
461    if (s1.length() != s2.length())
462      return "Strings differ in length: " + Integer.toString(s1.length()) + " vs " + Integer.toString(s2.length())
463          + " but match to the end of the shortest";
464    return null;
465  }
466
467  public static String resourceNameToFile(String name) throws IOException {
468    return resourceNameToFile(null, name);
469  }
470
471  private static boolean fileForPathExists(String path) throws IOException {
472    return ManagedFileAccess.file(path).exists();
473  }
474
475  public static String generateResourcePath(String subFolder, String name) throws IOException {
476    String path = Utilities.path(System.getProperty("user.dir"), "src", "test", "resources", subFolder, name);
477    BaseTestingUtilities.createParentDirIfNotExists(Paths.get(path));
478    return path;
479  }
480
481  public static String resourceNameToFile(String subFolder, String name) throws IOException {
482
483    final String resourcePath = (subFolder != null ? subFolder + "/" : "") + name;
484    final String filePathFromClassLoader = TestingUtilities.class.getClassLoader().getResource(resourcePath).getPath();
485
486    if (fileForPathExists(filePathFromClassLoader)) {
487      return filePathFromClassLoader;
488    } else {
489      final Path newFilePath = (subFolder != null) ? Paths.get("target", subFolder, name) : Paths.get("target", name);
490      copyResourceToNewFile(resourcePath, newFilePath);
491      return newFilePath.toString();
492    }
493  }
494
495  private static void copyResourceToNewFile(String resourcePath, Path newFilePath) throws IOException {
496    BaseTestingUtilities.createParentDirIfNotExists(newFilePath);
497    ResourceLoaderTests.copyResourceToFile(TestingUtilities.class, newFilePath, resourcePath);
498  }
499
500  public static String loadTestResource(String... paths) throws IOException {
501    /**
502     * This 'if' condition checks to see if the fhir-test-cases project
503     * (https://github.com/FHIR/fhir-test-cases) is installed locally at the same
504     * directory level as the core library project is. If so, the test case data is
505     * read directly from that project, instead of the imported maven dependency
506     * jar. It is important, that if you want to test against the dependency
507     * imported from sonatype nexus, instead of your local copy, you need to either
508     * change the name of the project directory to something other than
509     * 'fhir-test-cases', or move it to another location, not at the same directory
510     * level as the core project.
511     */
512
513    String dir = TestConfig.getInstance().getFhirTestCasesDirectory();
514    if (dir == null && FhirSettings.hasFhirTestCasesPath()) {
515      dir = FhirSettings.getFhirTestCasesPath();
516    }
517    if (dir != null && ManagedFileAccess.csfile(dir).exists()) {
518      String n = Utilities.path(dir, Utilities.path(paths));
519      // ok, we'll resolve this locally
520      return FileUtilities.fileToString(ManagedFileAccess.csfile(n));
521    } else {
522      // resolve from the package
523      String contents;
524      String classpath = ("/org/hl7/fhir/testcases/" + Utilities.pathURL(paths));
525      try (InputStream inputStream = BaseTestingUtilities.class.getResourceAsStream(classpath)) {
526        if (inputStream == null) {
527          throw new IOException("Can't find file on classpath: " + classpath);
528        }
529        contents = IOUtils.toString(inputStream, java.nio.charset.StandardCharsets.UTF_8);
530      }
531      return contents;
532    }
533  }
534
535  public static boolean runningAsSurefire() {
536    return "true".equals(System.getProperty("runningAsSurefire") != null ? System.getProperty("runningAsSurefire").toLowerCase(Locale.ENGLISH) : "");
537  }
538}