001/* 002 * Portions of this software was developed by employees of the National Institute 003 * of Standards and Technology (NIST), an agency of the Federal Government and is 004 * being made available as a public service. Pursuant to title 17 United States 005 * Code Section 105, works of NIST employees are not subject to copyright 006 * protection in the United States. This software may be subject to foreign 007 * copyright. Permission in the United States and in foreign countries, to the 008 * extent that NIST may hold copyright, to use, copy, modify, create derivative 009 * works, and distribute this software and its documentation without fee is hereby 010 * granted on a non-exclusive basis, provided that this notice and disclaimer 011 * of warranty appears in all copies. 012 * 013 * THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND, EITHER 014 * EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY 015 * THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF 016 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND FREEDOM FROM 017 * INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION WILL CONFORM TO THE 018 * SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE ERROR FREE. IN NO EVENT 019 * SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO, DIRECT, 020 * INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES, ARISING OUT OF, RESULTING FROM, 021 * OR IN ANY WAY CONNECTED WITH THIS SOFTWARE, WHETHER OR NOT BASED UPON WARRANTY, 022 * CONTRACT, TORT, OR OTHERWISE, WHETHER OR NOT INJURY WAS SUSTAINED BY PERSONS OR 023 * PROPERTY OR OTHERWISE, AND WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT 024 * OF THE RESULTS OF, OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER. 025 */ 026 027package gov.nist.secauto.metaschema.model.testing; 028 029import static org.junit.jupiter.api.Assertions.assertEquals; 030 031import gov.nist.secauto.metaschema.core.model.IModule; 032import gov.nist.secauto.metaschema.core.model.MetaschemaException; 033import gov.nist.secauto.metaschema.core.model.validation.IContentValidator; 034import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding; 035import gov.nist.secauto.metaschema.core.model.validation.IValidationResult; 036import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding; 037import gov.nist.secauto.metaschema.core.model.xml.ModuleLoader; 038import gov.nist.secauto.metaschema.core.util.ObjectUtils; 039import gov.nist.secauto.metaschema.databind.DefaultBindingContext; 040import gov.nist.secauto.metaschema.databind.IBindingContext; 041import gov.nist.secauto.metaschema.databind.io.Format; 042import gov.nist.secauto.metaschema.databind.io.ISerializer; 043import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.ContentCaseType; 044import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.GenerateSchemaDocument.GenerateSchema; 045import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.MetaschemaDocument; 046import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestCollectionDocument.TestCollection; 047import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestScenarioDocument.TestScenario; 048import gov.nist.secauto.metaschema.model.testing.xml.xmlbeans.TestSuiteDocument; 049 050import org.apache.logging.log4j.LogBuilder; 051import org.apache.logging.log4j.LogManager; 052import org.apache.logging.log4j.Logger; 053import org.apache.xmlbeans.XmlException; 054import org.apache.xmlbeans.XmlOptions; 055import org.junit.jupiter.api.DynamicContainer; 056import org.junit.jupiter.api.DynamicNode; 057import org.junit.jupiter.api.DynamicTest; 058import org.junit.platform.commons.JUnitException; 059 060import java.io.IOException; 061import java.io.Writer; 062import java.net.URI; 063import java.net.URISyntaxException; 064import java.net.URL; 065import java.nio.charset.StandardCharsets; 066import java.nio.file.FileVisitResult; 067import java.nio.file.Files; 068import java.nio.file.OpenOption; 069import java.nio.file.Path; 070import java.nio.file.SimpleFileVisitor; 071import java.nio.file.StandardOpenOption; 072import java.nio.file.attribute.BasicFileAttributes; 073import java.util.concurrent.ExecutionException; 074import java.util.concurrent.ExecutorService; 075import java.util.concurrent.Executors; 076import java.util.concurrent.Future; 077import java.util.function.BiFunction; 078import java.util.function.Function; 079import java.util.function.Supplier; 080import java.util.stream.Stream; 081 082import edu.umd.cs.findbugs.annotations.NonNull; 083import edu.umd.cs.findbugs.annotations.Nullable; 084 085public abstract class AbstractTestSuite { 086 private static final Logger LOGGER = LogManager.getLogger(AbstractTestSuite.class); 087 private static final ModuleLoader LOADER = new ModuleLoader(); 088 089 private static final boolean DELETE_RESULTS_ON_EXIT = false; 090 091 static { 092 LOADER.allowEntityResolution(); 093 } 094 095 @NonNull 096 protected abstract Format getRequiredContentFormat(); 097 098 @NonNull 099 protected abstract URI getTestSuiteURI(); 100 101 @NonNull 102 protected abstract Path getGenerationPath(); 103 104 @NonNull 105 protected abstract BiFunction<IModule, Writer, Void> getGeneratorSupplier(); 106 107 @Nullable 108 protected abstract Supplier<? extends IContentValidator> getSchemaValidatorSupplier(); 109 110 @NonNull 111 protected abstract Function<Path, ? extends IContentValidator> getContentValidatorSupplier(); 112 113 protected Stream<DynamicNode> testFactory() { 114 try { 115 return generateTests(); 116 } catch (XmlException | IOException ex) { 117 throw new JUnitException("Unable to generate tests", ex); 118 } 119 } 120 121 private Stream<DynamicNode> generateTests() throws XmlException, IOException { 122 XmlOptions options = new XmlOptions(); 123 options.setBaseURI(null); 124 options.setLoadLineNumbers(); 125 126 Path generationPath = getGenerationPath(); 127 if (Files.exists(generationPath)) { 128 if (!Files.isDirectory(generationPath)) { 129 throw new JUnitException(String.format("Generation path '%s' exists and is not a directory", generationPath)); 130 } 131 } else { 132 Files.createDirectories(generationPath); 133 } 134 135 URI testSuiteUri = getTestSuiteURI(); 136 URL testSuiteUrl = testSuiteUri.toURL(); 137 TestSuiteDocument directive = TestSuiteDocument.Factory.parse(testSuiteUrl, options); 138 return directive.getTestSuite().getTestCollectionList().stream() 139 .flatMap( 140 collection -> Stream.of(generateCollection(ObjectUtils.notNull(collection), testSuiteUri, generationPath))); 141 } 142 143 protected void deleteCollectionOnExit(Path path) { 144 if (path != null) { 145 Runtime.getRuntime().addShutdownHook(new Thread( // NOPMD - this is not a webapp 146 new Runnable() { 147 @Override 148 public void run() { 149 try { 150 Files.walkFileTree(path, new SimpleFileVisitor<Path>() { 151 @Override 152 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { 153 Files.delete(file); 154 return FileVisitResult.CONTINUE; 155 } 156 157 @Override 158 public FileVisitResult postVisitDirectory(Path dir, IOException ex) throws IOException { 159 if (ex == null) { 160 Files.delete(dir); 161 return FileVisitResult.CONTINUE; 162 } 163 // directory iteration failed for some reason 164 throw ex; 165 } 166 }); 167 } catch (IOException ex) { 168 throw new IllegalStateException("Failed to delete collection: " + path, ex); 169 } 170 } 171 })); 172 } 173 } 174 175 private DynamicContainer generateCollection(@NonNull TestCollection collection, @NonNull URI testSuiteUri, 176 @NonNull Path generationPath) { 177 URI collectionUri = testSuiteUri.resolve(collection.getLocation()); 178 assert collectionUri != null; 179 180 LOGGER.atInfo().log("Collection: " + collectionUri); 181 Path collectionGenerationPath; 182 try { 183 collectionGenerationPath = ObjectUtils.notNull(Files.createTempDirectory(generationPath, "collection-")); 184 assert collectionGenerationPath != null; 185 if (DELETE_RESULTS_ON_EXIT) { 186 deleteCollectionOnExit(collectionGenerationPath); 187 } 188 } catch (IOException ex) { 189 throw new JUnitException("Unable to create collection temp directory", ex); 190 } 191 192 return DynamicContainer.dynamicContainer( 193 collection.getName(), 194 testSuiteUri, 195 collection.getTestScenarioList().stream() 196 .flatMap(scenario -> { 197 assert scenario != null; 198 return Stream.of(generateScenario(scenario, collectionUri, collectionGenerationPath)); 199 }) 200 .sequential()); 201 } 202 203 protected void produceSchema(@NonNull IModule module, @NonNull Path schemaPath) throws IOException { 204 produceSchema(module, schemaPath, getGeneratorSupplier()); 205 } 206 207 protected void produceSchema(@NonNull IModule module, @NonNull Path schemaPath, 208 @NonNull BiFunction<IModule, Writer, Void> schemaProducer) throws IOException { 209 Path parentDir = schemaPath.getParent(); 210 if (parentDir != null && !Files.exists(parentDir)) { 211 Files.createDirectories(parentDir); 212 } 213 214 try (Writer writer = Files.newBufferedWriter( 215 schemaPath, 216 StandardCharsets.UTF_8, 217 getWriteOpenOptions())) { 218 schemaProducer.apply(module, writer); 219 } 220 LOGGER.atInfo().log("Produced schema '{}' for module '{}'", schemaPath, module.getLocation()); 221 } 222 223 protected OpenOption[] getWriteOpenOptions() { 224 return new OpenOption[] { 225 StandardOpenOption.CREATE, 226 StandardOpenOption.WRITE, 227 StandardOpenOption.TRUNCATE_EXISTING 228 }; 229 } 230 231 private DynamicContainer generateScenario(@NonNull TestScenario scenario, @NonNull URI collectionUri, 232 @NonNull Path collectionGenerationPath) { 233 Path scenarioGenerationPath; 234 try { 235 scenarioGenerationPath = Files.createTempDirectory(collectionGenerationPath, "scenario-"); 236 } catch (IOException ex) { 237 throw new JUnitException("Unable to create scenario temp directory", ex); 238 } 239 240 try { 241 // create the directories the schema will be stored in 242 Files.createDirectories(scenarioGenerationPath); 243 } catch (IOException ex) { 244 throw new JUnitException("Unable to create test directories for path: " + scenarioGenerationPath, ex); 245 } 246 247 GenerateSchema generateSchema = scenario.getGenerateSchema(); 248 MetaschemaDocument.Metaschema metaschemaDirective = generateSchema.getMetaschema(); 249 URI metaschemaUri = collectionUri.resolve(metaschemaDirective.getLocation()); 250 251 ExecutorService executor = Executors.newSingleThreadExecutor(); // NOPMD - intentional use of threads 252 Future<IModule> loadModuleFuture = executor.submit(() -> { 253 IModule module; 254 try { 255 module = LOADER.load(ObjectUtils.notNull(metaschemaUri.toURL())); 256 } catch (IOException | MetaschemaException ex) { 257 throw new JUnitException("Unable to generate schema for Module: " + metaschemaUri, ex); 258 } 259 return module; 260 }); 261 262 Future<Path> generateSchemaFuture = executor.submit(() -> { 263 Path schemaPath; 264 // determine what file to use for the schema 265 try { 266 schemaPath = Files.createTempFile(scenarioGenerationPath, "", "-schema"); 267 } catch (IOException ex) { 268 throw new JUnitException("Unable to create schema temp file", ex); 269 } 270 IModule module = loadModuleFuture.get(); 271 produceSchema(ObjectUtils.notNull(module), ObjectUtils.notNull(schemaPath)); 272 return schemaPath; 273 }); 274 275 Future<IBindingContext> dynamicBindingContextFuture = executor.submit(() -> { 276 IModule module = loadModuleFuture.get(); 277 IBindingContext context; 278 try { 279 context = new DefaultBindingContext(); 280 context.registerModule( 281 ObjectUtils.notNull(module), 282 ObjectUtils.notNull(scenarioGenerationPath)); 283 } catch (Exception ex) { // NOPMD - intentional 284 throw new JUnitException("Unable to generate classes for metaschema: " + metaschemaUri, ex); 285 } 286 return context; 287 }); 288 assert dynamicBindingContextFuture != null; 289 290 Future<IContentValidator> contentValidatorFuture = executor.submit(() -> { 291 Path schemaPath = generateSchemaFuture.get(); 292 return getContentValidatorSupplier().apply(schemaPath); 293 }); 294 assert contentValidatorFuture != null; 295 296 // build a test container for the generate and validate steps 297 DynamicTest validateSchema = DynamicTest.dynamicTest( 298 "Validate Schema", 299 () -> { 300 Supplier<? extends IContentValidator> supplier = getSchemaValidatorSupplier(); 301 if (supplier != null) { 302 Path schemaPath; 303 try { 304 schemaPath = ObjectUtils.requireNonNull(generateSchemaFuture.get()); 305 } catch (ExecutionException ex) { 306 throw new JUnitException( // NOPMD - cause is relevant, exception is not 307 "failed to generate schema", ex.getCause()); 308 } 309 validate(ObjectUtils.requireNonNull(supplier.get()), schemaPath); 310 } 311 }); 312 313 Stream<? extends DynamicNode> contentTests; 314 { 315 contentTests = scenario.getValidationCaseList().stream() 316 .flatMap(contentCase -> { 317 assert contentCase != null; 318 DynamicTest test 319 = generateValidationCase(contentCase, dynamicBindingContextFuture, contentValidatorFuture, 320 collectionUri, ObjectUtils.notNull(scenarioGenerationPath)); 321 return test == null ? Stream.empty() : Stream.of(test); 322 }).sequential(); 323 } 324 325 return DynamicContainer.dynamicContainer( 326 scenario.getName(), 327 metaschemaUri, 328 Stream.concat(Stream.of(validateSchema), contentTests).sequential()); 329 } 330 331 @SuppressWarnings("unchecked") 332 protected Path convertContent(URI contentUri, @NonNull Path generationPath, @NonNull IBindingContext context) 333 throws IOException { 334 Object object; 335 try { 336 object = context.newBoundLoader().load(ObjectUtils.notNull(contentUri.toURL())); 337 } catch (URISyntaxException ex) { 338 throw new IOException(ex); 339 } 340 341 if (!Files.exists(generationPath)) { 342 Files.createDirectories(generationPath); 343 } 344 345 Path convertedContetPath; 346 try { 347 convertedContetPath = ObjectUtils.notNull(Files.createTempFile(generationPath, "", "-content")); 348 } catch (IOException ex) { 349 throw new JUnitException("Unable to create schema temp file", ex); 350 } 351 352 @SuppressWarnings("rawtypes") ISerializer serializer 353 = context.newSerializer(getRequiredContentFormat(), object.getClass()); 354 serializer.serialize(object, convertedContetPath, getWriteOpenOptions()); 355 356 return convertedContetPath; 357 } 358 359 private DynamicTest generateValidationCase( 360 @NonNull ContentCaseType contentCase, 361 @NonNull Future<IBindingContext> contextFuture, 362 @NonNull Future<IContentValidator> contentValidatorFuture, 363 @NonNull URI collectionUri, 364 @NonNull Path generationPath) { 365 366 URI contentUri = collectionUri.resolve(contentCase.getLocation()); 367 368 Format format = contentCase.getSourceFormat(); 369 DynamicTest retval = null; 370 if (getRequiredContentFormat().equals(format)) { 371 retval = DynamicTest.dynamicTest( 372 String.format("Validate %s=%s: %s", format, contentCase.getValidationResult(), 373 contentCase.getLocation()), 374 contentUri, 375 () -> { 376 IContentValidator contentValidator; 377 try { 378 contentValidator = contentValidatorFuture.get(); 379 } catch (ExecutionException ex) { 380 throw new JUnitException( // NOPMD - cause is relevant, exception is not 381 "failed to produce the content validator", ex.getCause()); 382 } 383 384 assertEquals( 385 contentCase.getValidationResult(), 386 validate( 387 ObjectUtils.notNull(contentValidator), ObjectUtils.notNull(contentUri.toURL())), 388 "validation did not match expectation"); 389 }); 390 } else if (contentCase.getValidationResult()) { 391 retval = DynamicTest.dynamicTest( 392 String.format("Convert and Validate %s=%s: %s", format, contentCase.getValidationResult(), 393 contentCase.getLocation()), 394 contentUri, 395 () -> { 396 IBindingContext context; 397 try { 398 context = contextFuture.get(); 399 } catch (ExecutionException ex) { 400 throw new JUnitException( // NOPMD - cause is relevant, exception is not 401 "failed to produce the content validator", ex.getCause()); 402 } 403 assert context != null; 404 405 Path convertedContetPath; 406 try { 407 convertedContetPath = convertContent(contentUri, generationPath, context); 408 } catch (Exception ex) { // NOPMD - intentional 409 throw new JUnitException("failed to convert content: " + contentUri, ex); 410 } 411 412 IContentValidator contentValidator; 413 try { 414 contentValidator = contentValidatorFuture.get(); 415 } catch (ExecutionException ex) { 416 throw new JUnitException( // NOPMD - cause is relevant, exception is not 417 "failed to produce the content validator", 418 ex.getCause()); 419 } 420 assertEquals(contentCase.getValidationResult(), 421 validate( 422 ObjectUtils.notNull(contentValidator), 423 ObjectUtils.notNull(convertedContetPath.toUri().toURL())), 424 String.format("validation of '%s' did not match expectation", convertedContetPath)); 425 }); 426 } 427 return retval; 428 } 429 430 private static boolean validate(@NonNull IContentValidator validator, @NonNull URL target) throws IOException { 431 IValidationResult schemaValidationResult; 432 try { 433 schemaValidationResult = validator.validate(target); 434 } catch (URISyntaxException ex) { 435 throw new IOException(ex); 436 } 437 return processValidationResult(schemaValidationResult); 438 } 439 440 protected static boolean validate(@NonNull IContentValidator validator, @NonNull Path target) throws IOException { 441 IValidationResult schemaValidationResult = validator.validate(target); 442 if (!schemaValidationResult.isPassing()) { 443 LOGGER.atError().log("Schema validation failed for: {}", target); 444 } 445 return processValidationResult(schemaValidationResult); 446 } 447 448 private static boolean processValidationResult(IValidationResult schemaValidationResult) { 449 for (IValidationFinding finding : schemaValidationResult.getFindings()) { 450 logFinding(ObjectUtils.notNull(finding)); 451 } 452 return schemaValidationResult.isPassing(); 453 } 454 455 private static void logFinding(@NonNull IValidationFinding finding) { 456 LogBuilder logBuilder; 457 switch (finding.getSeverity()) { 458 case CRITICAL: 459 logBuilder = LOGGER.atFatal(); 460 break; 461 case ERROR: 462 logBuilder = LOGGER.atError(); 463 break; 464 case WARNING: 465 logBuilder = LOGGER.atWarn(); 466 break; 467 case INFORMATIONAL: 468 logBuilder = LOGGER.atInfo(); 469 break; 470 default: 471 throw new IllegalArgumentException("Unknown level: " + finding.getSeverity().name()); 472 } 473 474 // if (finding.getCause() != null) { 475 // logBuilder.withThrowable(finding.getCause()); 476 // } 477 478 if (finding instanceof JsonValidationFinding) { 479 JsonValidationFinding jsonFinding = (JsonValidationFinding) finding; 480 logBuilder.log("[{}] {}", jsonFinding.getCause().getPointerToViolation(), finding.getMessage()); 481 } else { 482 logBuilder.log("{}", finding.getMessage()); 483 } 484 } 485}