diff --git a/pom.xml b/pom.xml index 66fe36e..521b03c 100644 --- a/pom.xml +++ b/pom.xml @@ -56,11 +56,32 @@ 3.12.2 test - + + + org.mockito + mockito-junit-jupiter + 2.22.0 + test + + + + org.junit.jupiter + junit-jupiter-api + 5.1.1 + test + + + + org.junit.jupiter + junit-jupiter-params + 5.1.1 + test + + org.junit.jupiter - junit-jupiter - 5.5.1 + junit-jupiter-engine + 5.1.1 test diff --git a/src/main/java/software/amazon/cloudformation/resource/Validator.java b/src/main/java/software/amazon/cloudformation/resource/Validator.java index 09e6439..3ee761d 100644 --- a/src/main/java/software/amazon/cloudformation/resource/Validator.java +++ b/src/main/java/software/amazon/cloudformation/resource/Validator.java @@ -20,7 +20,11 @@ import lombok.Builder; import org.everit.json.schema.Schema; +import org.everit.json.schema.loader.SchemaClient; 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; @@ -28,35 +32,52 @@ public class Validator implements SchemaValidator { - private static final String JSON_SCHEMA_ID = "https://json-schema.org/draft-07/schema"; + private static final String ID_KEY = "$id"; private static final String JSON_SCHEMA_PATH = "/schema/schema"; - private static final String METASCHEMA_PATH = "/schema/provider.definition.schema.v1.json"; + private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json"; + /** + * resource definition schema ("resource schema schema"). All resource schemas + * are validated against this one and JSON schema draft v7 below. + */ private final JSONObject definitionSchemaJsonObject; + /** + * locally cached draft-07 JSON schema. All resource schemas are validated + * 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 + * client in unit tests to be able to control how remote refs are resolved. + */ + private final SchemaClient downloader; + + Validator(SchemaClient downloader) { + this(loadResourceAsJSON(JSON_SCHEMA_PATH), loadResourceAsJSON(RESOURCE_DEFINITION_SCHEMA_PATH), downloader); + } + + private Validator(JSONObject jsonSchema, + JSONObject definitionSchema, + SchemaClient downloader) { + this.jsonSchemaObject = jsonSchema; + this.definitionSchemaJsonObject = definitionSchema; + this.downloader = downloader; + } @Builder public Validator() { - // local copy of the draft-07 schema used to avoid remote reference calls - jsonSchemaObject = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(JSON_SCHEMA_PATH))); - definitionSchemaJsonObject = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(METASCHEMA_PATH))); + this(new DefaultSchemaClient()); } @Override public void validateObject(final JSONObject modelObject, final JSONObject definitionSchemaObject) throws ValidationException { + final SchemaLoaderBuilder loader = getSchemaLoader(definitionSchemaObject); + try { - final URI schemaURI = new URI(JSON_SCHEMA_ID); - final SchemaLoader loader = SchemaLoader.builder().schemaJson(definitionSchemaObject) - // registers the local schema with the draft-07 url - .registerSchemaByURI(schemaURI, jsonSchemaObject).draftV7Support().build(); - final Schema schema = loader.load().build(); - - try { - schema.validate(modelObject); // throws a ValidationException if this object is invalid - } catch (final org.everit.json.schema.ValidationException e) { - throw ValidationException.newScrubbedException(e); - } - } catch (final URISyntaxException e) { - throw new RuntimeException("Invalid URI format for JSON schema."); + final Schema schema = loader.build().load().build(); + schema.validate(modelObject); // throws a ValidationException if this object is invalid + } catch (final org.everit.json.schema.ValidationException e) { + throw ValidationException.newScrubbedException(e); } } @@ -69,6 +90,82 @@ public void validateObject(final JSONObject modelObject, final JSONObject defini */ public void validateResourceDefinition(final JSONObject definition) throws ValidationException { 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(); + } + + /** + * 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. + * + * @param resourceDefinition - actual resource definition (not resource definition schema) + * @return + */ + public Schema.Builder getResourceSchemaBuilder(final JSONObject resourceDefinition) { + final SchemaLoaderBuilder loaderBuilder = getSchemaLoader(resourceDefinition); + registerMetaSchema(loaderBuilder, definitionSchemaJsonObject); + + final SchemaLoader loader = loaderBuilder.build(); + try { + return loader.load(); + } catch (org.everit.json.schema.SchemaException e) { + throw new ValidationException(e.getMessage(), e.getSchemaLocation(), e); + } + } + + /** + * Convenience method - creates a SchemaLoaderBuilder with cached JSON draft-07 meta-schema + * + * @param schemaObject + * @return + */ + private SchemaLoaderBuilder getSchemaLoader(JSONObject schemaObject) { + final SchemaLoaderBuilder builder = SchemaLoader + .builder() + .schemaJson(schemaObject) + .draftV7Support() + .schemaClient(downloader); + // registers the local schema with the draft-07 url + registerMetaSchema(builder, jsonSchemaObject); + return builder; } + /** + * 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); + } + } + + private static JSONObject loadResourceAsJSON(String path) { + return new JSONObject(new JSONTokener(Validator.class.getResourceAsStream(path))); + } } diff --git a/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java b/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java index 0b553b3..6209804 100644 --- a/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java +++ b/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java @@ -48,6 +48,15 @@ public ValidationException(final String message, this(message, Collections.emptyList(), keyword, schemaPointer); } + public ValidationException(final String message, + final String schemaPointer, + final Exception cause) { + super(message, cause); + this.causingExceptions = Collections.emptyList(); + this.keyword = ""; + this.schemaPointer = schemaPointer; + } + public ValidationException(final String message, final List causingExceptions, final String keyword, diff --git a/src/main/resources/schema/provider.definition.schema.v1.json b/src/main/resources/schema/provider.definition.schema.v1.json index a656626..12174ba 100644 --- a/src/main/resources/schema/provider.definition.schema.v1.json +++ b/src/main/resources/schema/provider.definition.schema.v1.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft-07/schema#", - "$id": "provider.definition.schema.v1.json", + "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json", "title": "CloudFormation Resource Provider Definition MetaSchema", "description": "This schema validates a CloudFormation resource provider definition.", "definitions": { diff --git a/src/main/resources/schema/schema b/src/main/resources/schema/schema index 2261e0d..33627e9 100644 --- a/src/main/resources/schema/schema +++ b/src/main/resources/schema/schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft-07/schema#", - "$id": "https://json-schema.org/draft-07/schema#", + "$id": "https://json-schema.org/draft-07/schema", "title": "Core schema meta-schema", "definitions": { "schemaArray": { diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java new file mode 100644 index 0000000..9a8147f --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java @@ -0,0 +1,126 @@ +/* +* Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ +package software.amazon.cloudformation.resource; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static software.amazon.cloudformation.resource.ValidatorTest.loadJSON; + +import org.everit.json.schema.Schema; +import org.everit.json.schema.loader.SchemaClient; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.verification.VerificationMode; + +import software.amazon.cloudformation.resource.exceptions.ValidationException; + +/** + * + */ +@ExtendWith(MockitoExtension.class) +public class ValidatorRefResolutionTests { + + public static final String RESOURCE_DEFINITION_PATH = "/valid-with-refs.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"; + + @Mock + private SchemaClient downloader; + private Validator validator; + + @BeforeEach + public void beforeEach() { + when(downloader.get(expectedRefUrl)).thenAnswer(x -> ValidatorTest.getResourceAsStream(COMMON_TYPES_PATH)); + + this.validator = new Validator(downloader); + } + + @Test + public void loadResourceSchema_validRelativeRef_shouldSucceed() { + + JSONObject schema = loadJSON(RESOURCE_DEFINITION_PATH); + validator.validateResourceDefinition(schema); + + // valid-with-refs.json contains two refs pointing at locations inside + // common.types.v1.json + // Everit will attempt to download the remote schema once for each $ref - it + // doesn't cache + // remote schemas. Expect the downloader to be called twice + verify(downloader, twice()).get(expectedRefUrl); + } + + /** + * expect a valid resource schema contains a ref to a non-existent property in a + * remote meta-schema + */ + @Test + public void loadResourceSchema_invalidRelativeRef_shouldThrow() { + + JSONObject badSchema = loadJSON("/invalid-bad-ref.json"); + + assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> { + validator.validateResourceDefinition(badSchema); + }); + } + + /** example of using Validator to validate a json data files */ + @Test + public void validateTemplateAgainstResourceSchema_valid_shouldSucceed() { + + JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH); + Schema schema = validator.loadResourceSchema(resourceDefinition); + + schema.validate(getSampleTemplate()); + } + + /** + * template that contains an invalid value in one of its properties fails + * validation + */ + @Test + public void validateTemplateAgainsResourceSchema_invalid_shoudThrow() { + JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH); + Schema schema = validator.loadResourceSchema(resourceDefinition); + + final JSONObject template = getSampleTemplate(); + template.put("propertyB", "not.an.IP.address"); + + assertThatExceptionOfType(org.everit.json.schema.ValidationException.class).isThrownBy(() -> schema.validate(template)); + } + + /** + * resource schema located at RESOURCE_DEFINITION_PATH declares two properties: + * "Time" in ISO 8601 format (UTC only) and "propertyB" - an IP address Both + * fields are declares as refs to common.types.v1.json. "Time" is marked as + * required property getSampleTemplate constructs a JSON object with a single + * Time property. + */ + private JSONObject getSampleTemplate() { + final JSONObject template = new JSONObject(); + template.put("Time", "2019-12-12T10:10:22.212Z"); + return template; + } + + private static VerificationMode twice() { + return Mockito.times(2); + } + +} diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java index cee1a36..3bddefe 100644 --- a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java +++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java @@ -24,6 +24,7 @@ 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,6 +36,7 @@ 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 TYPE_NAME_KEY = "typeName"; @@ -333,14 +335,6 @@ public void validateDefinition_validHandlerSection_shouldNotThrow() { validator.validateResourceDefinition(definition); } - @Test - public void validateDefinition_validRelativeRef_shouldNotThrow() { - final JSONObject definition = new JSONObject(new JSONTokener(this.getClass() - .getResourceAsStream("/valid-with-relative-ref.json"))); - - validator.validateResourceDefinition(definition); - } - @ParameterizedTest @ValueSource(strings = { "ftp://example.com", "http://example.com", "git://example.com", "https://", }) public void validateDefinition_nonMatchingDocumentationUrl_shouldThrow(final String documentationUrl) { @@ -418,4 +412,42 @@ public void validateExample_exampleResource_shouldBeValid() throws IOException { } } + /** + * 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))); + } catch (Throwable ex) { + System.out.println("path: " + path); + throw ex; + } + } + + static InputStream getResourceAsStream(String path) { + return ValidatorRefResolutionTests.class.getResourceAsStream(path); + } } diff --git a/src/test/resources/common.types.v1.json b/src/test/resources/common.types.v1.json new file mode 100644 index 0000000..17b5319 --- /dev/null +++ b/src/test/resources/common.types.v1.json @@ -0,0 +1,34 @@ +{ + "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/common.types.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Common Resource Type Schemas", + "definitions": { + "cidrBlock": { + "$comment": "TODO: regex could be more strict, for example this allows the cidr 999.999.999.999 and 999.999.999.999/32", + "description": "Classless Inter-Domain Routing (CIDR) block", + "type": "string", + "pattern": "^([0-9]{1,3}.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$" + }, + "ipV6CidrBlock": { + "$comment": "TODO: find sane regex for this", + "description": "IPV6 Classless Inter-Domain Routing (CIDR) block", + "type": "string" + }, + "iso8601UTC": { + "description": "The date value in ISO 8601 format. The timezone is always UTC. (YYYY-MM-DDThh:mm:ssZ)", + "type": "string", + "pattern": "^([0-2]\\d{3})-(0[0-9]|1[0-2])-([0-2]\\d|3[01])T([01]\\d|2[0-4]):([0-5]\\d):([0-6]\\d)((\\.\\d{3})?)Z$" + }, + "ipv4Address": { + "description": "IPV4 address", + "type": "string" + }, + "ipv6Address": { + "description": "IPV6 address", + "type": "string" + } + }, + "primaryIdentifier": [ + "#/" + ] +} diff --git a/src/test/resources/invalid-bad-ref.json b/src/test/resources/invalid-bad-ref.json new file mode 100644 index 0000000..09006e4 --- /dev/null +++ b/src/test/resources/invalid-bad-ref.json @@ -0,0 +1,21 @@ +{ + "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/resource-definition-with-refs.json", + "typeName": "CFN::Test::Resource", + "description": "propertyB definition uses a ref pointer to a non-existent remote schema location", + "sourceUrl": "https://mycorp.com/my-repo.git", + "properties": { + "Time": { + "$ref": "./common.types.v1.json#/definitions/iso8601UTC" + }, + "propertyB": { + "type": "array", + "items": { + "$ref": "./common.types.v1.json#/definitions/badProperty" + } + } + }, + "primaryIdentifier": [ + "/properties/Time" + ], + "additionalProperties": false +} diff --git a/src/test/resources/valid-with-refs.json b/src/test/resources/valid-with-refs.json new file mode 100644 index 0000000..1cb1606 --- /dev/null +++ b/src/test/resources/valid-with-refs.json @@ -0,0 +1,22 @@ +{ + "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/resource-definition-with-refs.json", + "$schema": "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json", + "typeName": "CFN::Test::Resource", + "description": "Simple resource definition with ref pointers to definitions in a remote schema", + "sourceUrl": "https://mycorp.com/my-repo.git", + "properties": { + "Time": { + "$ref": "./common.types.v1.json#/definitions/iso8601UTC" + }, + "propertyB": { + "type": "array", + "items": { + "$ref": "./common.types.v1.json#/definitions/ipv4Address" + } + } + }, + "primaryIdentifier": [ + "/properties/Time" + ], + "additionalProperties": false +} diff --git a/src/test/resources/valid-with-relative-ref.json b/src/test/resources/valid-with-relative-ref.json deleted file mode 100644 index 9c36a2b..0000000 --- a/src/test/resources/valid-with-relative-ref.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/valid-with-relative-ref.json#", - "typeName": "AWS::Test::WithExternalRefs", - "description": "A test schema for unit tests.", - "properties": { - "StringProperty": { - "$ref": "aws-ecs-taskdefinition.json#/properties/blah" - } - }, - "additionalProperties": false, - "primaryIdentifier": [ - "/properties/StringProperty" - ] -}