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