diff --git a/src/main/java/software/amazon/cloudformation/resource/ResourceTypeSchema.java b/src/main/java/software/amazon/cloudformation/resource/ResourceTypeSchema.java index 7f256fe..b41e3db 100644 --- a/src/main/java/software/amazon/cloudformation/resource/ResourceTypeSchema.java +++ b/src/main/java/software/amazon/cloudformation/resource/ResourceTypeSchema.java @@ -25,33 +25,36 @@ import org.everit.json.schema.JSONPointer; import org.everit.json.schema.JSONPointerException; -import org.everit.json.schema.ObjectSchema; import org.everit.json.schema.PublicJSONPointer; -import org.everit.json.schema.loader.SchemaLoader; +import org.everit.json.schema.Schema; import org.json.JSONObject; -import org.json.JSONTokener; import software.amazon.cloudformation.resource.exceptions.ValidationException; @Getter -public class ResourceTypeSchema extends ObjectSchema { +public class ResourceTypeSchema { + + private static final Validator VALIDATOR = new Validator(); private final Map unprocessedProperties = new HashMap<>(); private final String sourceUrl; private final String documentationUrl; private final String typeName; + private final String schemaUrl; // $schema + private final List createOnlyProperties = new ArrayList<>(); private final List deprecatedProperties = new ArrayList<>(); private final List primaryIdentifier = new ArrayList<>(); private final List> additionalIdentifiers = new ArrayList<>(); private final List readOnlyProperties = new ArrayList<>(); private final List writeOnlyProperties = new ArrayList<>(); + private final Schema schema; - public ResourceTypeSchema(final ObjectSchema.Builder builder) { - super(builder); + public ResourceTypeSchema(Schema schema) { - super.getUnprocessedProperties().forEach(this.unprocessedProperties::put); + this.schema = schema; + schema.getUnprocessedProperties().forEach(this.unprocessedProperties::put); this.sourceUrl = this.unprocessedProperties.containsKey("sourceUrl") ? this.unprocessedProperties.get("sourceUrl").toString() @@ -67,6 +70,11 @@ public ResourceTypeSchema(final ObjectSchema.Builder builder) { this.typeName = this.unprocessedProperties.get("typeName").toString(); this.unprocessedProperties.remove("typeName"); + this.schemaUrl = this.unprocessedProperties.containsKey("$schema") + ? this.unprocessedProperties.get("$schema").toString() + : null; + this.unprocessedProperties.remove("$schema"); + this.unprocessedProperties.computeIfPresent("createOnlyProperties", (k, v) -> { ((ArrayList) v).forEach(p -> this.createOnlyProperties.add(new JSONPointer(p.toString()))); return null; @@ -102,19 +110,14 @@ public ResourceTypeSchema(final ObjectSchema.Builder builder) { }); } - public static ResourceTypeSchema load(final JSONObject schemaJson) { - // first validate incoming resource schema against definition schema - Validator.builder().build().validateObject(schemaJson, new JSONObject(new JSONTokener(ResourceTypeSchema.class - .getResourceAsStream(SchemaValidator.DEFINITION_SCHEMA_PATH)))); + public static ResourceTypeSchema load(final JSONObject resourceDefinition) { - // now extract identifiers from resource schema - final SchemaLoader loader = SchemaLoader.builder().schemaJson(schemaJson) - // registers the local schema with the draft-07 url - .draftV7Support().build(); - - final ObjectSchema.Builder builder = (ObjectSchema.Builder) loader.load(); + Schema schema = VALIDATOR.loadResourceDefinitionSchema(resourceDefinition); + return new ResourceTypeSchema(schema); + } - return new ResourceTypeSchema(builder); + public String getDescription() { + return schema.getDescription(); } public List getCreateOnlyPropertiesAsStrings() throws ValidationException { @@ -143,7 +146,6 @@ public List getWriteOnlyPropertiesAsStrings() throws ValidationException return this.writeOnlyProperties.stream().map(JSONPointer::toString).collect(Collectors.toList()); } - @Override public Map getUnprocessedProperties() { return Collections.unmodifiableMap(this.unprocessedProperties); } @@ -171,4 +173,8 @@ public static void removeProperty(final PublicJSONPointer property, final JSONOb // do nothing, as this indicates the model does not have a value for the pointer } } + + public void validate(JSONObject json) { + getSchema().validate(json); + } } diff --git a/src/main/java/software/amazon/cloudformation/resource/Validator.java b/src/main/java/software/amazon/cloudformation/resource/Validator.java index f11534f..c912e50 100644 --- a/src/main/java/software/amazon/cloudformation/resource/Validator.java +++ b/src/main/java/software/amazon/cloudformation/resource/Validator.java @@ -24,6 +24,7 @@ import org.everit.json.schema.loader.SchemaLoader; import org.everit.json.schema.loader.SchemaLoader.SchemaLoaderBuilder; import org.everit.json.schema.loader.internal.DefaultSchemaClient; +import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; @@ -35,7 +36,7 @@ public class Validator implements SchemaValidator { private static final URI JSON_SCHEMA_URI_HTTP = newURI("http://json-schema.org/draft-07/schema"); private static final URI RESOURCE_DEFINITION_SCHEMA_URI = newURI( "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json"); - + private static final String ID_KEY = "$id"; private static final String JSON_SCHEMA_PATH = "/schema/schema"; private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json"; @@ -50,7 +51,6 @@ public class Validator implements SchemaValidator { * against it */ private final JSONObject jsonSchemaObject; - /** * this is what SchemaLoader uses to download remote $refs. Not necessarily an * HTTP client, see the docs for details. We override the default SchemaClient @@ -75,6 +75,15 @@ public Validator() { this(new DefaultSchemaClient()); } + /** + * builds a Schema instance that can be used to validate Resource Definition Schema as a JSON object + */ + private Schema makeResourceDefinitionSchema() { + SchemaLoaderBuilder builder = getSchemaLoader(); + builder.schemaJson(definitionSchemaJsonObject); + return builder.build().load().build(); + } + @Override public void validateObject(final JSONObject modelObject, final JSONObject definitionSchemaObject) throws ValidationException { final SchemaLoaderBuilder loader = getSchemaLoader(definitionSchemaObject); @@ -88,44 +97,49 @@ public void validateObject(final JSONObject modelObject, final JSONObject defini } /** - * Perform JSON Schema validation for the input resource definition against the + * Performs JSON Schema validation for the input resource definition against the * resource provider definition schema * - * @param definition JSON-encoded resource definition + * @param resourceDefinition JSON-encoded resource definition * @throws ValidationException Thrown for any schema validation errors */ - public void validateResourceDefinition(final JSONObject definition) throws ValidationException { - // inject/replace $schema URI to ensure that provider definition schema is used - definition.put("$schema", RESOURCE_DEFINITION_SCHEMA_URI.toString()); - validateObject(definition, definitionSchemaJsonObject); - // validateObject cannot validate schema-specific attributes. For example if definition - // contains "propertyA": { "$ref":"./some-non-existent-location.json#definitions/PropertyX"} - // validateObject will succeed, because all it cares about is that "$ref" is a URI - // In order to validate that $ref points at an existing location in an existing document - // we have to "load" the schema - loadResourceSchema(definition); - } - - public Schema loadResourceSchema(final JSONObject resourceDefinition) { - return getResourceSchemaBuilder(resourceDefinition).build(); + void validateResourceDefinition(final JSONObject resourceDefinition) { + // loading resource definition always performs validation + loadResourceDefinitionSchema(resourceDefinition); } /** - * returns Schema.Builder with pre-loaded JSON draft-07 meta-schema and resource definition meta-schema - * (resource.definition.schema.v1.json). Resulting Schema.Builder can be used to build a schema that - * can be used to validate parts of CloudFormation template. + * create a Schema instance that can be used to validate CloudFormation resources. * - * @param resourceDefinition - actual resource definition (not resource definition schema) - * @return + * @param resourceDefinition - CloudFormation Resource Provider Schema (Resource Definition) + * @throws ValidationException if supplied resourceDefinition is invalid. + * @return - Schema instance for the given Resource Definition */ - public Schema.Builder getResourceSchemaBuilder(final JSONObject resourceDefinition) { - final SchemaLoaderBuilder loaderBuilder = getSchemaLoader(resourceDefinition); - loaderBuilder.registerSchemaByURI(RESOURCE_DEFINITION_SCHEMA_URI, definitionSchemaJsonObject); + public Schema loadResourceDefinitionSchema(final JSONObject resourceDefinition) { + + // inject/replace $schema URI to ensure that provider definition schema is used + resourceDefinition.put("$schema", RESOURCE_DEFINITION_SCHEMA_URI.toString()); - final SchemaLoader loader = loaderBuilder.build(); try { - return loader.load(); - } catch (org.everit.json.schema.SchemaException e) { + // step 1: validate resourceDefinition as a JSON object + // this validator cannot validate schema-specific attributes. For example if definition + // contains "propertyA": { "$ref":"./some-non-existent-location.json#definitions/PropertyX"} + // validateObject will succeed, because all it cares about is that "$ref" is a URI + // In order to validate that $ref points at an existing location in an existing document + // we have to "load" the schema + Schema resourceDefValidator = makeResourceDefinitionSchema(); + resourceDefValidator.validate(resourceDefinition); + + // step 2: load resource definition as a Schema that can be used to validate resource models; + // definitionSchemaJsonObject becomes a meta-schema + SchemaLoaderBuilder builder = getSchemaLoader(); + registerMetaSchema(builder, jsonSchemaObject); + builder.schemaJson(resourceDefinition); + // when resource definition is loaded as a schema, $refs are resolved and validated + return builder.build().load().build(); + } catch (final org.everit.json.schema.ValidationException e) { + throw ValidationException.newScrubbedException(e); + } catch (final org.everit.json.schema.SchemaException e) { throw new ValidationException(e.getMessage(), e.getSchemaLocation(), e); } } @@ -137,14 +151,18 @@ public Schema.Builder getResourceSchemaBuilder(final JSONObject resourceDefin * @return */ private SchemaLoaderBuilder getSchemaLoader(JSONObject schemaObject) { + return getSchemaLoader().schemaJson(schemaObject); + } + + /** get schema-builder preloaded with JSON draft V7 meta-schema */ + private SchemaLoaderBuilder getSchemaLoader() { final SchemaLoaderBuilder builder = SchemaLoader .builder() - .schemaJson(schemaObject) .draftV7Support() .schemaClient(downloader); // registers the local schema with the draft-07 url - // registered twice because we've seen some confusion around this in the past + // draftV7 schema is registered twice because - once for HTTP and once for HTTPS URIs builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTP, jsonSchemaObject); builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTPS, jsonSchemaObject); @@ -163,4 +181,30 @@ static URI newURI(final String uri) { throw new RuntimeException(uri); } } + + /** + * Register a meta-schema with the SchemaLoaderBuilder. The meta-schema $id is used to generate schema URI + * This has the effect of caching the meta-schema. When SchemaLoaderBuilder is used to build the Schema object, + * the cached version will be used. No calls to remote URLs will be made. + * Validator caches JSON schema (/resources/schema) and Resource Definition Schema + * (/resources/provider.definition.schema.v1.json) + * + * @param loaderBuilder + * @param schema meta-schema JSONObject to be cached. Must have a valid $id property + */ + void registerMetaSchema(final SchemaLoaderBuilder loaderBuilder, JSONObject schema) { + try { + String id = schema.getString(ID_KEY); + if (id.isEmpty()) { + throw new ValidationException("Invalid $id value", "$id", "[empty string]"); + } + final URI uri = new URI(id); + loaderBuilder.registerSchemaByURI(uri, schema); + } catch (URISyntaxException e) { + throw new ValidationException("Invalid $id value", "$id", e); + } catch (JSONException e) { + // $id is missing or not a string + throw new ValidationException("Invalid $id value", "$id", e); + } + } } diff --git a/src/test/java/software/amazon/cloudformation/resource/ResourceTypeSchemaTest.java b/src/test/java/software/amazon/cloudformation/resource/ResourceTypeSchemaTest.java index db96826..840d8fd 100644 --- a/src/test/java/software/amazon/cloudformation/resource/ResourceTypeSchemaTest.java +++ b/src/test/java/software/amazon/cloudformation/resource/ResourceTypeSchemaTest.java @@ -16,12 +16,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static software.amazon.cloudformation.resource.ValidatorTest.loadJSON; import java.util.List; import org.everit.json.schema.PublicJSONPointer; import org.json.JSONObject; -import org.json.JSONTokener; import org.junit.jupiter.api.Test; import software.amazon.cloudformation.resource.exceptions.ValidationException; @@ -29,13 +29,14 @@ public class ResourceTypeSchemaTest { private static final String TEST_SCHEMA_PATH = "/test-schema.json"; private static final String EMPTY_SCHEMA_PATH = "/empty-schema.json"; + private static final String SCHEMA_WITH_ONEOF = "/valid-with-oneof-schema.json"; private static final String MINIMAL_SCHEMA_PATH = "/minimal-schema.json"; private static final String NO_ADDITIONAL_PROPERTIES_SCHEMA_PATH = "/no-additional-properties-schema.json"; private static final String WRITEONLY_MODEL_PATH = "/write-only-model.json"; @Test public void getProperties() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); assertThat(schema.getDescription()).isEqualTo("A test schema for unit tests."); @@ -46,7 +47,7 @@ public void getProperties() { @Test public void getCreateOnlyProperties() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List result = schema.getCreateOnlyPropertiesAsStrings(); @@ -56,7 +57,7 @@ public void getCreateOnlyProperties() { @Test public void getDeprecatedProperties() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List result = schema.getDeprecatedPropertiesAsStrings(); @@ -65,7 +66,7 @@ public void getDeprecatedProperties() { @Test public void getPrimaryIdentifier() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List result = schema.getPrimaryIdentifierAsStrings(); @@ -74,7 +75,7 @@ public void getPrimaryIdentifier() { @Test public void getAdditionalIdentifiers() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List> result = schema.getAdditionalIdentifiersAsStrings(); @@ -84,7 +85,7 @@ public void getAdditionalIdentifiers() { @Test public void getReadOnlyProperties() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List result = schema.getReadOnlyPropertiesAsStrings(); @@ -93,7 +94,7 @@ public void getReadOnlyProperties() { @Test public void getWriteOnlyProperties() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); List result = schema.getWriteOnlyPropertiesAsStrings(); @@ -102,7 +103,7 @@ public void getWriteOnlyProperties() { @Test public void invalidSchema_shouldThrow() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(EMPTY_SCHEMA_PATH))); + JSONObject o = loadJSON(EMPTY_SCHEMA_PATH); assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> ResourceTypeSchema.load(o)).withNoCause() .withMessage("#/properties: minimum size: [1], found: [0]"); @@ -110,7 +111,7 @@ public void invalidSchema_shouldThrow() { @Test public void invalidSchema_noAdditionalProperties_shouldThrow() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(NO_ADDITIONAL_PROPERTIES_SCHEMA_PATH))); + JSONObject o = loadJSON(NO_ADDITIONAL_PROPERTIES_SCHEMA_PATH); assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> ResourceTypeSchema.load(o)).withNoCause() .withMessage("#: required key [additionalProperties] not found"); @@ -118,7 +119,7 @@ public void invalidSchema_noAdditionalProperties_shouldThrow() { @Test public void minimalSchema_hasNoSemantics() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(MINIMAL_SCHEMA_PATH))); + JSONObject o = loadJSON(MINIMAL_SCHEMA_PATH); final ResourceTypeSchema schema = ResourceTypeSchema.load(o); assertThat(schema.getDescription()).isEqualTo("A test schema for unit tests."); @@ -136,9 +137,9 @@ public void minimalSchema_hasNoSemantics() { @Test public void removeWriteOnlyProperties_hasWriteOnlyProperties_shouldRemove() { - JSONObject o = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + JSONObject o = loadJSON(TEST_SCHEMA_PATH); ResourceTypeSchema schema = ResourceTypeSchema.load(o); - JSONObject resourceModel = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(WRITEONLY_MODEL_PATH))); + JSONObject resourceModel = loadJSON(WRITEONLY_MODEL_PATH); schema.removeWriteOnlyProperties(resourceModel); @@ -151,4 +152,46 @@ public void removeWriteOnlyProperties_hasWriteOnlyProperties_shouldRemove() { // ensure that other non writeOnlyProperty is not removed assertThat(resourceModel.has("propertyB")).isTrue(); } + + @Test + public void validSchema_withOneOf_shouldSucceed() { + JSONObject resource = loadJSON("/valid-with-oneof-schema.json"); + final ResourceTypeSchema schema = ResourceTypeSchema.load(resource); + } + + /** + * validate a valid model against a schema containing conditionals, like "oneOf" + */ + @Test + public void schemaWithOneOf_validateCorrectModel_shouldSucceed() { + JSONObject resourceDefinition = loadJSON(SCHEMA_WITH_ONEOF); + ResourceTypeSchema schema = ResourceTypeSchema.load(resourceDefinition); + + // SCHEMA_WITH_ONEOF requires either propertyA or propertyB + // both models below should validate successfully + final JSONObject modelWithPropA = getEmptyModel().put("propertyA", "property a, not b"); + final JSONObject modelWithPropB = getEmptyModel().put("propertyB", "property b, not a"); + + schema.validate(modelWithPropA); + schema.validate(modelWithPropB); + } + + /** + * validate an invalid model against a schema containing conditionals ("oneOf") + */ + @Test + public void schemaWithOneOf_validateIncorrectModel_shouldThrow() { + JSONObject resourceDefinition = loadJSON(SCHEMA_WITH_ONEOF); + ResourceTypeSchema schema = ResourceTypeSchema.load(resourceDefinition); + + final JSONObject modelWithNeitherAnorB = getEmptyModel(); + + assertThatExceptionOfType(org.everit.json.schema.ValidationException.class).isThrownBy( + () -> schema.validate(modelWithNeitherAnorB)); + } + + static JSONObject getEmptyModel() { + return new JSONObject().put("id", "required.identifier"); + } + } diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java index 38b09c0..de8bbf1 100644 --- a/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java +++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java @@ -31,10 +31,13 @@ import software.amazon.cloudformation.resource.exceptions.ValidationException; +/** + * + */ @ExtendWith(MockitoExtension.class) public class ValidatorRefResolutionTests { - public static final String RESOURCE_DEFINITION_PATH = "/valid-with-refs.json"; + public static final String RESOURCE_DEFINITION_PATH = "/valid-with-refs-schema.json"; private final static String COMMON_TYPES_PATH = "/common.types.v1.json"; private final String expectedRefUrl = "https://schema.cloudformation.us-east-1.amazonaws.com/common.types.v1.json"; @@ -50,7 +53,7 @@ public void beforeEach() { } @Test - public void loadResourceSchema_validRelativeRef_shouldSucceed() { + public void validateResourceDefinition_validRelativeRef_shouldSucceed() { JSONObject schema = loadJSON(RESOURCE_DEFINITION_PATH); validator.validateResourceDefinition(schema); @@ -67,34 +70,37 @@ public void loadResourceSchema_validRelativeRef_shouldSucceed() { * remote meta-schema */ @Test - public void loadResourceSchema_invalidRelativeRef_shouldThrow() { + public void validateResourceDefinition_invalidRelativeRef_shouldThrow() { - JSONObject badSchema = loadJSON("/invalid-bad-ref.json"); + JSONObject badSchema = loadJSON("/invalid-bad-ref-schema.json"); assertThatExceptionOfType(ValidationException.class) .isThrownBy(() -> validator.validateResourceDefinition(badSchema)); } - /** example of using Validator to validate a json data files */ + /** example of using ResourceTypeSchema to validate a model */ @Test - public void validateTemplateAgainstResourceSchema_valid_shouldSucceed() { + public void validateModel_containsValidRefs_shouldSucceed() { JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH); - Schema schema = validator.loadResourceSchema(resourceDefinition); + Schema rawSchema = validator.loadResourceDefinitionSchema(resourceDefinition); + ResourceTypeSchema schema = new ResourceTypeSchema(rawSchema); - schema.validate(getSampleTemplate()); + schema.validate(getValidModelWithRefs()); } /** - * template that contains an invalid value in one of its properties fails + * model that contains an invalid value in one of its properties fails * validation */ @Test - public void validateTemplateAgainsResourceSchema_invalid_shoudThrow() { + public void validateModel_containsBadRef_shoudThrow() { JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH); - Schema schema = validator.loadResourceSchema(resourceDefinition); + Schema rawSchema = validator.loadResourceDefinitionSchema(resourceDefinition); + ResourceTypeSchema schema = new ResourceTypeSchema(rawSchema); - final JSONObject template = getSampleTemplate(); + final JSONObject template = getValidModelWithRefs(); + // make the model invalid by adding a property containing a malformed IP address template.put("propertyB", "not.an.IP.address"); assertThatExceptionOfType(org.everit.json.schema.ValidationException.class) @@ -108,7 +114,7 @@ public void validateTemplateAgainsResourceSchema_invalid_shoudThrow() { * required property getSampleTemplate constructs a JSON object with a single * Time property. */ - private JSONObject getSampleTemplate() { + private JSONObject getValidModelWithRefs() { return new JSONObject().put("Time", "2019-12-12T10:10:22.212Z"); } } diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java index eba1b17..9442723 100644 --- a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java +++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java @@ -20,10 +20,12 @@ import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.everit.json.schema.loader.SchemaLoader; import org.json.JSONObject; import org.json.JSONTokener; import org.junit.jupiter.api.BeforeEach; @@ -35,8 +37,10 @@ import software.amazon.cloudformation.resource.exceptions.ValidationException; public class ValidatorTest { + private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json"; private static final String TEST_SCHEMA_PATH = "/test-schema.json"; private static final String TEST_VALUE_SCHEMA_PATH = "/scrubbed-values-schema.json"; + private static final String SCHEMA_WITH_HANDLERS_PATH = "/valid-with-handlers-schema.json"; private static final String TYPE_NAME_KEY = "typeName"; private static final String PROPERTIES_KEY = "properties"; private static final String DESCRIPTION_KEY = "description"; @@ -126,8 +130,7 @@ public void validateObject_invalidStringValue_messageShouldNotContainValue(final final JSONObject object = new JSONObject().put("StringProperty", value); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_VALUE_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_VALUE_SCHEMA_PATH)), ValidationException.class); assertThat(e.getSchemaPointer()).isEqualTo("#/StringProperty"); @@ -168,8 +171,7 @@ public void validateObject_invalidArrayValue_messageShouldNotContainValue(final final JSONObject object = new JSONObject().put("ArrayProperty", values); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_VALUE_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_VALUE_SCHEMA_PATH)), ValidationException.class); assertThat(e.getKeyword()).isEqualTo(keyword); @@ -188,8 +190,7 @@ public void validateObject_invalidNumValue_messageShouldNotContainValue(final St final JSONObject object = new JSONObject().put(propName, Integer.valueOf(numAsString)); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_VALUE_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_VALUE_SCHEMA_PATH)), ValidationException.class); assertThat(e.getKeyword()).isEqualTo(keyword); @@ -228,8 +229,7 @@ public void validateObject_invalidPatternProperties_messageShouldNotContainValue final JSONObject object = new JSONObject().put("MapProperty", new JSONObject().put("def", "val")); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_VALUE_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_VALUE_SCHEMA_PATH)), ValidationException.class); assertThat(e.getSchemaPointer()).isEqualTo("#/MapProperty"); @@ -247,8 +247,7 @@ public void validateObject_invalidCombiner_messageShouldNotContainValue(final St final JSONObject object = new JSONObject().put(propName, propVal); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_VALUE_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_VALUE_SCHEMA_PATH)), ValidationException.class); final String pointer = "#/" + propName; @@ -271,8 +270,7 @@ public void validateObject_invalidObjectMultiple_messageShouldNotContainValue() .put("propertyX", propValue).put("propertyY", propValue); final ValidationException e = catchThrowableOfType( - () -> validator.validateObject(object, - new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH)))), + () -> validator.validateObject(object, loadJSON(TEST_SCHEMA_PATH)), ValidationException.class); assertThat(e.getCausingExceptions()).hasSize(3); @@ -294,7 +292,7 @@ public void validateDefinition_validMinimalDefinition_shouldNotThrow() { @Test public void validateDefinition_validExampleDefinition_shouldNotThrow() { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(TEST_SCHEMA_PATH))); + final JSONObject definition = loadJSON(TEST_SCHEMA_PATH); validator.validateResourceDefinition(definition); } @@ -318,8 +316,7 @@ public void validateDefinition_invalidDefinitionNoProperties_shouldThrow() { @Test public void validateDefinition_invalidHandlerSection_shouldThrow() { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/invalid-handlers.json"))); + final JSONObject definition = loadJSON("/invalid-handlers-schema.json"); assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> validator.validateResourceDefinition(definition)) .withNoCause().withMessage("#/handlers/read: required key [permissions] not found"); @@ -329,8 +326,7 @@ public void validateDefinition_invalidHandlerSection_shouldThrow() { @ValueSource(ints = { 1, 721 }) public void validateDefinition_invalidTimeout_shouldThrow(final int timeout) { // modifying the valid-with-handlers.json to add invalid timeout - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/valid-with-handlers.json"))); + final JSONObject definition = loadJSON(SCHEMA_WITH_HANDLERS_PATH); final JSONObject createDefinition = definition.getJSONObject("handlers").getJSONObject("create"); createDefinition.put("timeoutInMinutes", timeout); @@ -344,8 +340,7 @@ public void validateDefinition_invalidTimeout_shouldThrow(final int timeout) { @ParameterizedTest @ValueSource(ints = { 2, 120, 720 }) public void validateDefinition_withTimeout_shouldNotThrow(final int timeout) { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/valid-with-handlers.json"))); + final JSONObject definition = loadJSON(SCHEMA_WITH_HANDLERS_PATH); final JSONObject createDefinition = definition.getJSONObject("handlers").getJSONObject("create"); createDefinition.put("timeoutInMinutes", timeout); @@ -356,8 +351,7 @@ public void validateDefinition_withTimeout_shouldNotThrow(final int timeout) { @ParameterizedTest @ValueSource(strings = { "create", "update", "delete", "read", "list" }) public void validateDefinition_timeoutAllowed_shouldNotThrow(final String handlerType) { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/valid-with-handlers.json"))); + final JSONObject definition = loadJSON(SCHEMA_WITH_HANDLERS_PATH); final JSONObject handlerDefinition = definition.getJSONObject("handlers").getJSONObject(handlerType); handlerDefinition.put("timeoutInMinutes", 30); @@ -367,8 +361,7 @@ public void validateDefinition_timeoutAllowed_shouldNotThrow(final String handle @Test public void validateDefinition_validHandlerSection_shouldNotThrow() { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/valid-with-handlers.json"))); + final JSONObject definition = loadJSON(SCHEMA_WITH_HANDLERS_PATH); validator.validateResourceDefinition(definition); } @@ -473,16 +466,43 @@ public void validateDefinition_idKeyword_shouldBeAllowed() { } @Test - public void validateExample_exampleResource_shouldBeValid() throws IOException { - try (InputStream stream = this.getClass().getResourceAsStream("/examples/resource/initech.tps.report.v1.json")) { - final JSONObject example = new JSONObject(new JSONTokener(stream)); - validator.validateResourceDefinition(example); - } + public void validateExample_exampleResourceProviderSchema_shouldBeValid() throws IOException { + final JSONObject example = loadJSON("/examples/resource/initech.tps.report.v1.json"); + validator.validateResourceDefinition(example); + } + + /** + * trivial coverage test: cannot cache a schema if it has an invalid $id + */ + @ParameterizedTest + @ValueSource(strings = { ":invalid/uri", "" }) + public void registerMetaSchema_invalidRelativeRef_shouldThrow(String uri) { + + JSONObject badSchema = loadJSON(RESOURCE_DEFINITION_SCHEMA_PATH); + badSchema.put("$id", uri); + assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> { + validator.registerMetaSchema(SchemaLoader.builder(), badSchema); + }); + } + + /** + * trivial coverage test: cannot cache a schema if it has no $id + */ + @Test + public void registerMetaSchema_nullId_shouldThrow() { + JSONObject badSchema = loadJSON(RESOURCE_DEFINITION_SCHEMA_PATH); + badSchema.remove("$id"); + assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> { + validator.registerMetaSchema(SchemaLoader.builder(), badSchema); + }); } static JSONObject loadJSON(String path) { - try { - return new JSONObject(new JSONTokener(ValidatorTest.getResourceAsStream(path))); + try (InputStream stream = getResourceAsStream(path)) { + return new JSONObject(new JSONTokener(stream)); + } catch (IOException ex) { + System.out.println("path: " + path); + throw new UncheckedIOException(ex); } catch (Throwable ex) { System.out.println("path: " + path); throw ex; @@ -492,4 +512,5 @@ static JSONObject loadJSON(String path) { static InputStream getResourceAsStream(String path) { return ValidatorRefResolutionTests.class.getResourceAsStream(path); } + } diff --git a/src/test/resources/invalid-bad-ref.json b/src/test/resources/invalid-bad-ref-schema.json similarity index 100% rename from src/test/resources/invalid-bad-ref.json rename to src/test/resources/invalid-bad-ref-schema.json diff --git a/src/test/resources/invalid-handlers.json b/src/test/resources/invalid-handlers-schema.json similarity index 100% rename from src/test/resources/invalid-handlers.json rename to src/test/resources/invalid-handlers-schema.json diff --git a/src/test/resources/valid-with-handlers.json b/src/test/resources/valid-with-handlers-schema.json similarity index 100% rename from src/test/resources/valid-with-handlers.json rename to src/test/resources/valid-with-handlers-schema.json diff --git a/src/test/resources/valid-with-oneof-schema.json b/src/test/resources/valid-with-oneof-schema.json new file mode 100644 index 0000000..7d4dc30 --- /dev/null +++ b/src/test/resources/valid-with-oneof-schema.json @@ -0,0 +1,32 @@ +{ + "typeName": "AWS::Test::TestModel", + "description": "A test schema for unit tests.", + "sourceUrl": "https://mycorp.com/my-repo.git", + "properties": { + "id": { + "type": "string" + }, + "propertyA": { + "type": "string" + }, + "propertyB": { + "type": "string" + } + }, + "oneOf": [ + { + "required": [ + "propertyA" + ] + }, + { + "required": [ + "propertyB" + ] + } + ], + "primaryIdentifier": [ + "/properties/id" + ], + "additionalProperties": false +} diff --git a/src/test/resources/valid-with-refs.json b/src/test/resources/valid-with-refs-schema.json similarity index 100% rename from src/test/resources/valid-with-refs.json rename to src/test/resources/valid-with-refs-schema.json