001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.model.testing;
007
008import static org.junit.jupiter.api.Assertions.assertEquals;
009
010import gov.nist.secauto.metaschema.core.model.IModule;
011import gov.nist.secauto.metaschema.core.model.MetaschemaException;
012import gov.nist.secauto.metaschema.core.model.validation.IContentValidator;
013import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding;
014import gov.nist.secauto.metaschema.core.model.validation.IValidationResult;
015import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
016import gov.nist.secauto.metaschema.core.util.ObjectUtils;
017import gov.nist.secauto.metaschema.databind.DefaultBindingContext;
018import gov.nist.secauto.metaschema.databind.IBindingContext;
019import gov.nist.secauto.metaschema.databind.io.Format;
020import gov.nist.secauto.metaschema.databind.io.ISerializer;
021import gov.nist.secauto.metaschema.databind.model.metaschema.BindingModuleLoader;
022import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.ContentCaseType;
023import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.GenerateSchemaDocument.GenerateSchema;
024import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.MetaschemaDocument;
025import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestCollectionDocument.TestCollection;
026import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestScenarioDocument.TestScenario;
027import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestSuiteDocument;
028
029import org.apache.logging.log4j.LogBuilder;
030import org.apache.logging.log4j.LogManager;
031import org.apache.logging.log4j.Logger;
032import org.apache.xmlbeans.XmlException;
033import org.apache.xmlbeans.XmlOptions;
034import org.junit.jupiter.api.DynamicContainer;
035import org.junit.jupiter.api.DynamicNode;
036import org.junit.jupiter.api.DynamicTest;
037import org.junit.platform.commons.JUnitException;
038
039import java.io.IOException;
040import java.io.Writer;
041import java.net.URI;
042import java.net.URISyntaxException;
043import java.net.URL;
044import java.nio.charset.StandardCharsets;
045import java.nio.file.FileVisitResult;
046import java.nio.file.Files;
047import java.nio.file.OpenOption;
048import java.nio.file.Path;
049import java.nio.file.SimpleFileVisitor;
050import java.nio.file.StandardOpenOption;
051import java.nio.file.attribute.BasicFileAttributes;
052import java.util.function.BiFunction;
053import java.util.function.Function;
054import java.util.function.Supplier;
055import java.util.stream.Stream;
056
057import edu.umd.cs.findbugs.annotations.NonNull;
058import edu.umd.cs.findbugs.annotations.Nullable;
059import nl.talsmasoftware.lazy4j.Lazy;
060
061public abstract class AbstractTestSuite {
062  private static final Logger LOGGER = LogManager.getLogger(AbstractTestSuite.class);
063  private static final BindingModuleLoader LOADER;
064
065  private static final boolean DELETE_RESULTS_ON_EXIT = false;
066  private static final OpenOption[] OPEN_OPTIONS_TRUNCATE = {
067      StandardOpenOption.CREATE,
068      StandardOpenOption.WRITE,
069      StandardOpenOption.TRUNCATE_EXISTING
070  };
071
072  static {
073    IBindingContext bindingContext = new DefaultBindingContext();
074    LOADER = new BindingModuleLoader(bindingContext);
075    LOADER.allowEntityResolution();
076  }
077
078  /**
079   * Get the content format used by the test suite.
080   *
081   * @return the format
082   */
083  @NonNull
084  protected abstract Format getRequiredContentFormat();
085
086  /**
087   * Get the resource describing the tests to execute.
088   *
089   * @return the resource
090   */
091  @NonNull
092  protected abstract URI getTestSuiteURI();
093
094  /**
095   * Get the filesystem location to use for generating content.
096   *
097   * @return the filesystem path
098   */
099  @NonNull
100  protected abstract Path getGenerationPath();
101
102  /**
103   * Get the method used to generate a schema using a given Metaschema module and
104   * writer.
105   *
106   * @return the schema generator supplier
107   */
108  @NonNull
109  protected abstract BiFunction<IModule, Writer, Void> getSchemaGeneratorSupplier();
110
111  /**
112   * Get the method used to provide a schema validator.
113   *
114   * @return the method as a supplier
115   */
116  @Nullable
117  protected abstract Supplier<? extends IContentValidator> getSchemaValidatorSupplier();
118
119  /**
120   * Get the method used to provide a content validator.
121   *
122   * @return the method as a supplier
123   */
124  @NonNull
125  protected abstract Function<Path, ? extends IContentValidator> getContentValidatorSupplier();
126
127  /**
128   * Dynamically generate the unit tests.
129   *
130   * @return the steam of unit tests
131   */
132  @NonNull
133  protected Stream<DynamicNode> testFactory() {
134    try {
135      XmlOptions options = new XmlOptions();
136      options.setBaseURI(null);
137      options.setLoadLineNumbers();
138
139      Path generationPath = getGenerationPath();
140      if (Files.exists(generationPath)) {
141        if (!Files.isDirectory(generationPath)) {
142          throw new JUnitException(String.format("Generation path '%s' exists and is not a directory", generationPath));
143        }
144      } else {
145        Files.createDirectories(generationPath);
146      }
147
148      URI testSuiteUri = getTestSuiteURI();
149      URL testSuiteUrl = testSuiteUri.toURL();
150      TestSuiteDocument directive = TestSuiteDocument.Factory.parse(testSuiteUrl, options);
151      return ObjectUtils.notNull(directive.getTestSuite().getTestCollectionList().stream()
152          .flatMap(
153              collection -> Stream
154                  .of(generateCollection(ObjectUtils.notNull(collection), testSuiteUri, generationPath))));
155    } catch (XmlException | IOException ex) {
156      throw new JUnitException("Unable to generate tests", ex);
157    }
158  }
159
160  /**
161   * Configure removal of the provided directory after test execution.
162   *
163   * @param path
164   *          the directory to configure for removal
165   */
166  protected void deleteCollectionOnExit(@NonNull Path path) {
167    Runtime.getRuntime().addShutdownHook(new Thread( // NOPMD - this is not a webapp
168        () -> {
169          try {
170            Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
171              @Override
172              public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
173                Files.delete(file);
174                return FileVisitResult.CONTINUE;
175              }
176
177              @Override
178              public FileVisitResult postVisitDirectory(Path dir, IOException ex) throws IOException {
179                if (ex == null) {
180                  Files.delete(dir);
181                  return FileVisitResult.CONTINUE;
182                }
183                // directory iteration failed for some reason
184                throw ex;
185              }
186            });
187          } catch (IOException ex) {
188            throw new JUnitException("Failed to delete collection: " + path, ex);
189          }
190        }));
191  }
192
193  private DynamicContainer generateCollection(@NonNull TestCollection collection, @NonNull URI testSuiteUri,
194      @NonNull Path generationPath) {
195    URI collectionUri = testSuiteUri.resolve(collection.getLocation());
196    assert collectionUri != null;
197
198    LOGGER.atInfo().log("Collection: " + collectionUri);
199    Lazy<Path> collectionGenerationPath = ObjectUtils.notNull(Lazy.lazy(() -> {
200      Path retval;
201      try {
202        retval = ObjectUtils.requireNonNull(Files.createTempDirectory(generationPath, "collection-"));
203        if (DELETE_RESULTS_ON_EXIT) {
204          deleteCollectionOnExit(ObjectUtils.requireNonNull(retval));
205        }
206      } catch (IOException ex) {
207        throw new JUnitException("Unable to create collection temp directory", ex);
208      }
209      return retval;
210    }));
211
212    return DynamicContainer.dynamicContainer(
213        collection.getName(),
214        testSuiteUri,
215        collection.getTestScenarioList().stream()
216            .flatMap(scenario -> {
217              assert scenario != null;
218              return Stream.of(generateScenario(scenario, collectionUri, collectionGenerationPath));
219            })
220            .sequential());
221  }
222
223  /**
224   * Generate a schema for the provided module using the provided schema
225   * generator.
226   *
227   * @param module
228   *          the Metaschema module to generate the schema for
229   * @param schemaPath
230   *          the location to generate the schema
231   * @param schemaProducer
232   *          the method callback to use to generate the schema
233   * @throws IOException
234   *           if an error occurred while writing the schema
235   */
236  protected void generateSchema(
237      @NonNull IModule module,
238      @NonNull Path schemaPath,
239      @NonNull BiFunction<IModule, Writer, Void> schemaProducer) throws IOException {
240    Path parentDir = schemaPath.getParent();
241    if (parentDir != null && !Files.exists(parentDir)) {
242      Files.createDirectories(parentDir);
243    }
244
245    try (Writer writer = Files.newBufferedWriter(
246        schemaPath,
247        StandardCharsets.UTF_8,
248        getWriteOpenOptions())) {
249      schemaProducer.apply(module, writer);
250    }
251    LOGGER.atInfo().log("Produced schema '{}' for module '{}'", schemaPath, module.getLocation());
252  }
253
254  /**
255   * The the options for writing generated content.
256   *
257   * @return the options
258   */
259  protected OpenOption[] getWriteOpenOptions() {
260    return OPEN_OPTIONS_TRUNCATE;
261  }
262
263  private DynamicContainer generateScenario(
264      @NonNull TestScenario scenario,
265      @NonNull URI collectionUri,
266      @NonNull Lazy<Path> collectionGenerationPath) {
267    Lazy<Path> scenarioGenerationPath = Lazy.lazy(() -> {
268      Path retval;
269      try {
270        retval = Files.createTempDirectory(collectionGenerationPath.get(), "scenario-");
271      } catch (IOException ex) {
272        throw new JUnitException("Unable to create scenario temp directory", ex);
273      }
274      return retval;
275    });
276
277    // try {
278    // // create the directories the schema will be stored in
279    // Files.createDirectories(scenarioGenerationPath);
280    // } catch (IOException ex) {
281    // throw new JUnitException("Unable to create test directories for path: " +
282    // scenarioGenerationPath, ex);
283    // }
284
285    GenerateSchema generateSchema = scenario.getGenerateSchema();
286    MetaschemaDocument.Metaschema metaschemaDirective = generateSchema.getMetaschema();
287    URI metaschemaUri = collectionUri.resolve(metaschemaDirective.getLocation());
288
289    Lazy<IModule> lazyModule = Lazy.lazy(() -> {
290      IModule module;
291      try {
292        module = LOADER.load(ObjectUtils.notNull(metaschemaUri.toURL()));
293      } catch (IOException | MetaschemaException ex) {
294        throw new JUnitException("Unable to generate schema for Module: " + metaschemaUri, ex);
295      }
296      return module;
297    });
298
299    Lazy<Path> lazySchema = Lazy.lazy(() -> {
300      Path schemaPath;
301
302      String schemaExtension;
303      Format requiredContentFormat = getRequiredContentFormat();
304      switch (requiredContentFormat) {
305      case JSON:
306      case YAML:
307        schemaExtension = ".json";
308        break;
309      case XML:
310        schemaExtension = ".xsd";
311        break;
312      default:
313        throw new IllegalStateException();
314      }
315
316      // determine what file to use for the schema
317      try {
318        schemaPath = Files.createTempFile(scenarioGenerationPath.get(), "", "-schema" + schemaExtension);
319      } catch (IOException ex) {
320        throw new JUnitException("Unable to create schema temp file", ex);
321      }
322      IModule module = lazyModule.get();
323      try {
324        generateSchema(ObjectUtils.notNull(module), ObjectUtils.notNull(schemaPath), getSchemaGeneratorSupplier());
325      } catch (IOException ex) {
326        throw new IllegalStateException(ex);
327      }
328      return schemaPath;
329    });
330
331    Lazy<IBindingContext> lazyDynamicBindingContext = Lazy.lazy(() -> {
332      IModule module = lazyModule.get();
333      IBindingContext context;
334      try {
335        context = new DefaultBindingContext();
336        context.registerModule(
337            ObjectUtils.notNull(module),
338            ObjectUtils.notNull(scenarioGenerationPath.get()));
339      } catch (Exception ex) { // NOPMD - intentional
340        throw new JUnitException("Unable to generate classes for metaschema: " + metaschemaUri, ex);
341      }
342      return context;
343    });
344    assert lazyDynamicBindingContext != null;
345
346    Lazy<IContentValidator> lazyContentValidator = Lazy.lazy(() -> {
347      Path schemaPath = lazySchema.get();
348      return getContentValidatorSupplier().apply(schemaPath);
349    });
350    assert lazyContentValidator != null;
351
352    // build a test container for the generate and validate steps
353    DynamicTest validateSchema = DynamicTest.dynamicTest(
354        "Validate Schema",
355        () -> {
356          Supplier<? extends IContentValidator> supplier = getSchemaValidatorSupplier();
357          if (supplier != null) {
358            Path schemaPath;
359            try {
360              schemaPath = ObjectUtils.requireNonNull(lazySchema.get());
361            } catch (Exception ex) {
362              throw new JUnitException( // NOPMD - cause is relevant, exception is not
363                  "failed to generate schema", ex.getCause());
364            }
365            validateWithSchema(ObjectUtils.requireNonNull(supplier.get()), schemaPath);
366          }
367        });
368
369    Stream<? extends DynamicNode> contentTests;
370    {
371      contentTests = scenario.getValidationCaseList().stream()
372          .flatMap(contentCase -> {
373            assert contentCase != null;
374            DynamicTest test
375                = generateValidationCase(
376                    contentCase,
377                    lazyDynamicBindingContext,
378                    lazyContentValidator,
379                    collectionUri,
380                    ObjectUtils.notNull(scenarioGenerationPath));
381            return test == null ? Stream.empty() : Stream.of(test);
382          }).sequential();
383    }
384
385    return DynamicContainer.dynamicContainer(
386        scenario.getName(),
387        metaschemaUri,
388        Stream.concat(Stream.of(validateSchema), contentTests).sequential());
389  }
390
391  /**
392   * Perform content conversion.
393   *
394   * @param resource
395   *          the resource to convert
396   * @param generationPath
397   *          the path to write the converted resource to
398   * @param context
399   *          the Metaschema binding context
400   * @return the location of the converted content
401   * @throws IOException
402   *           if an error occurred while reading or writing content
403   * @see #getRequiredContentFormat()
404   */
405  protected Path convertContent(
406      @NonNull URI resource,
407      @NonNull Path generationPath,
408      @NonNull IBindingContext context)
409      throws IOException {
410    Object object;
411    try {
412      object = context.newBoundLoader().load(ObjectUtils.notNull(resource.toURL()));
413    } catch (URISyntaxException ex) {
414      throw new IOException(ex);
415    }
416
417    if (!Files.exists(generationPath)) {
418      Files.createDirectories(generationPath);
419    }
420
421    Path convertedContetPath;
422    try {
423      convertedContetPath = ObjectUtils.notNull(Files.createTempFile(generationPath, "", "-content"));
424    } catch (IOException ex) {
425      throw new JUnitException("Unable to create schema temp file", ex);
426    }
427
428    @SuppressWarnings("rawtypes") ISerializer serializer
429        = context.newSerializer(getRequiredContentFormat(), ObjectUtils.asType(object.getClass()));
430    serializer.serialize(ObjectUtils.asType(object), convertedContetPath, getWriteOpenOptions());
431
432    return convertedContetPath;
433  }
434
435  private DynamicTest generateValidationCase(
436      @NonNull ContentCaseType contentCase,
437      @NonNull Lazy<IBindingContext> lazyBindingContext,
438      @NonNull Lazy<IContentValidator> lazyContentValidator,
439      @NonNull URI collectionUri,
440      @NonNull Lazy<Path> generationPath) {
441
442    URI contentUri = ObjectUtils.notNull(collectionUri.resolve(contentCase.getLocation()));
443
444    Format format = contentCase.getSourceFormat();
445    DynamicTest retval = null;
446    if (getRequiredContentFormat().equals(format)) {
447      retval = DynamicTest.dynamicTest(
448          String.format("Validate %s=%s: %s", format, contentCase.getValidationResult(),
449              contentCase.getLocation()),
450          contentUri,
451          () -> {
452            IContentValidator contentValidator;
453            try {
454              contentValidator = lazyContentValidator.get();
455            } catch (Exception ex) {
456              throw new JUnitException( // NOPMD - cause is relevant, exception is not
457                  "failed to produce the content validator", ex.getCause());
458            }
459
460            assertEquals(
461                contentCase.getValidationResult(),
462                validateWithSchema(
463                    ObjectUtils.notNull(contentValidator), ObjectUtils.notNull(contentUri.toURL())),
464                "validation did not match expectation for: " + contentUri.toASCIIString());
465          });
466    } else if (contentCase.getValidationResult()) {
467      retval = DynamicTest.dynamicTest(
468          String.format("Convert and Validate %s=%s: %s", format, contentCase.getValidationResult(),
469              contentCase.getLocation()),
470          contentUri,
471          () -> {
472            IBindingContext context;
473            try {
474              context = lazyBindingContext.get();
475            } catch (Exception ex) {
476              throw new JUnitException( // NOPMD - cause is relevant, exception is not
477                  "failed to produce the content validator", ex.getCause());
478            }
479            assert context != null;
480
481            Path convertedContetPath;
482            try {
483              convertedContetPath = convertContent(contentUri, ObjectUtils.notNull(generationPath.get()), context);
484            } catch (Exception ex) { // NOPMD - intentional
485              throw new JUnitException("failed to convert content: " + contentUri, ex);
486            }
487
488            IContentValidator contentValidator;
489            try {
490              contentValidator = lazyContentValidator.get();
491            } catch (Exception ex) {
492              throw new JUnitException( // NOPMD - cause is relevant, exception is not
493                  "failed to produce the content validator",
494                  ex.getCause());
495            }
496            assertEquals(contentCase.getValidationResult(),
497                validateWithSchema(
498                    ObjectUtils.notNull(contentValidator),
499                    ObjectUtils.notNull(convertedContetPath.toUri().toURL())),
500                String.format("validation of '%s' did not match expectation", convertedContetPath));
501          });
502    }
503    return retval;
504  }
505
506  private static boolean validateWithSchema(@NonNull IContentValidator validator, @NonNull URL target)
507      throws IOException {
508    IValidationResult schemaValidationResult;
509    try {
510      schemaValidationResult = validator.validate(target);
511    } catch (URISyntaxException ex) {
512      throw new IOException(ex);
513    }
514    return processValidationResult(schemaValidationResult);
515  }
516
517  /**
518   * Use the provided validator to validate the provided target.
519   *
520   * @param validator
521   *          the content validator to use
522   * @param target
523   *          the resource to validate
524   * @return {@code true} if the content is valid or {@code false} otherwise
525   * @throws IOException
526   *           if an error occurred while reading the content
527   */
528  protected static boolean validateWithSchema(@NonNull IContentValidator validator, @NonNull Path target)
529      throws IOException {
530    IValidationResult schemaValidationResult = validator.validate(target);
531    if (!schemaValidationResult.isPassing()) {
532      LOGGER.atError().log("Schema validation failed for: {}", target);
533    }
534    return processValidationResult(schemaValidationResult);
535  }
536
537  private static boolean processValidationResult(IValidationResult schemaValidationResult) {
538    for (IValidationFinding finding : schemaValidationResult.getFindings()) {
539      logFinding(ObjectUtils.notNull(finding));
540    }
541    return schemaValidationResult.isPassing();
542  }
543
544  private static void logFinding(@NonNull IValidationFinding finding) {
545    LogBuilder logBuilder;
546    switch (finding.getSeverity()) {
547    case CRITICAL:
548      logBuilder = LOGGER.atFatal();
549      break;
550    case ERROR:
551      logBuilder = LOGGER.atError();
552      break;
553    case WARNING:
554      logBuilder = LOGGER.atWarn();
555      break;
556    case INFORMATIONAL:
557      logBuilder = LOGGER.atInfo();
558      break;
559    case DEBUG:
560      logBuilder = LOGGER.atDebug();
561      break;
562    default:
563      throw new IllegalArgumentException("Unknown level: " + finding.getSeverity().name());
564    }
565
566    // if (finding.getCause() != null) {
567    // logBuilder.withThrowable(finding.getCause());
568    // }
569
570    if (finding instanceof JsonValidationFinding) {
571      JsonValidationFinding jsonFinding = (JsonValidationFinding) finding;
572      logBuilder.log("[{}] {}", jsonFinding.getCause().getPointerToViolation(), finding.getMessage());
573    } else {
574      logBuilder.log("{}", finding.getMessage());
575    }
576  }
577}