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