-
Notifications
You must be signed in to change notification settings - Fork 4.5k
Extend SATs to capture UI limitations #21451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
e9d3c5b
2d33d3b
6f3c3ff
30ef769
fd69337
2bec7ba
ddfc1bf
4d19fc0
2800bf7
c1a6b17
95d51de
55a67bd
7b2fd46
7b8df03
e2f6f6e
f09fc2b
d4d9bdc
a6c2d7c
d58d6d9
ac93bbc
4b7691e
b41e311
5b402aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -76,12 +76,20 @@ def secret_property_names_fixture(): | |
) | ||
|
||
|
||
DATE_PATTERN = "^[0-9]{2}-[0-9]{2}-[0-9]{4}$" | ||
DATETIME_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2})?$" | ||
|
||
|
||
@pytest.mark.default_timeout(10) | ||
class TestSpec(BaseTest): | ||
|
||
spec_cache: ConnectorSpecification = None | ||
previous_spec_cache: ConnectorSpecification = None | ||
|
||
@pytest.fixture(name="connection_specification", scope="class") | ||
def connection_specification_fixture(self, connector_spec_dict: dict) -> dict: | ||
return connector_spec_dict["connectionSpecification"] | ||
|
||
@pytest.fixture(name="skip_backward_compatibility_tests") | ||
def skip_backward_compatibility_tests_fixture( | ||
self, | ||
|
@@ -219,22 +227,15 @@ def _property_can_store_secret(prop: dict) -> bool: | |
Some fields can not hold a secret by design, others can. | ||
Null type as well as boolean can not hold a secret value. | ||
A string, a number or an integer type can always store secrets. | ||
Objects and arrays can hold a secret in case they are generic, | ||
meaning their inner structure is not described in details with properties/items. | ||
Secret objects and arrays can not be rendered correctly in the UI: | ||
A field with a constant value can not hold a secret as well. | ||
""" | ||
unsecure_types = {"string", "integer", "number"} | ||
type_ = prop["type"] | ||
is_property_generic_object = type_ == "object" and not any( | ||
[prop.get("properties", {}), prop.get("anyOf", []), prop.get("oneOf", []), prop.get("allOf", [])] | ||
) | ||
is_property_generic_array = type_ == "array" and not any([prop.get("items", []), prop.get("prefixItems", [])]) | ||
is_property_constant_value = bool(prop.get("const")) | ||
can_store_secret = any( | ||
[ | ||
isinstance(type_, str) and type_ in unsecure_types, | ||
is_property_generic_object, | ||
is_property_generic_array, | ||
isinstance(type_, list) and (set(type_) & unsecure_types), | ||
] | ||
) | ||
|
@@ -252,7 +253,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log | |
secrets_exposed = [] | ||
non_secrets_hidden = [] | ||
spec_properties = connector_spec_dict["connectionSpecification"]["properties"] | ||
for type_path, value in dpath.util.search(spec_properties, "**/type", yielded=True): | ||
for type_path, type_value in dpath.util.search(spec_properties, "**/type", yielded=True): | ||
_, is_property_name_secret = self._is_spec_property_name_secret(type_path, secret_property_names) | ||
if not is_property_name_secret: | ||
continue | ||
|
@@ -268,7 +269,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log | |
|
||
if non_secrets_hidden: | ||
properties = "\n".join(non_secrets_hidden) | ||
detailed_logger.warning( | ||
pytest.fail( | ||
f"""Some properties are marked with `airbyte_secret` although they probably should not be. | ||
Please double check them. If they're okay, please fix this test. | ||
{properties}""" | ||
|
@@ -280,6 +281,139 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log | |
{properties}""" | ||
) | ||
|
||
def _fail_on_errors(self, errors: List[str]): | ||
if len(errors) > 0: | ||
pytest.fail("\n".join(errors)) | ||
|
||
def test_property_type_is_not_array(self, connector_specification: dict): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: the type should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test fails with : def test_property_type_is_not_array(self, connector_specification: dict):
E fixture 'connector_specification' not found
> available fixtures: acceptance_test_config, actual_connector_spec, base_path, cache, cache_discovered_catalog, cached_schemas, capfd, capfdbinary, caplog, capsys, capsysbinary, class_mocker, configured_catalog, configured_catalog_path, connection_specification, connector_config, connector_config_path, connector_setup, connector_spec, connector_spec_dict, connector_spec_path, cov, detailed_logger, discovered_catalog, docker_runner, doctest_namespace, empty_streams, expect_records_config, expected_records_by_stream, image_tag, invalid_connector_config, invalid_connector_config_path, malformed_connector_config, mocker, module_mocker, monkeypatch, no_cover, package_mocker, previous_cached_schemas, previous_connector_docker_runner, previous_connector_image_name, previous_connector_spec, previous_discovered_catalog, pull_docker_image, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, requests_mock, secret_property_names, session_mocker, skip_backward_compatibility_tests, test_strictness_level, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them. |
||
""" | ||
Each field has one or multiple types, but the UI only supports a single type and optionally "null" as a second type. | ||
""" | ||
errors = [] | ||
for type_path, type_value in dpath.util.search(connector_specification, "**/properties/*/type", yielded=True): | ||
if isinstance(type_value, List): | ||
number_of_types = len(type_value) | ||
if number_of_types != 2 and number_of_types != 1: | ||
errors.append( | ||
f"{type_path} is not either a simple type or an array of a simple type plus null: {type_value} (for example: type: [string, null])" | ||
) | ||
if number_of_types == 2 and type_value[1] != "null": | ||
errors.append( | ||
f"Second type of {type_path} is not null: {type_value}. Type can either be a simple type or an array of a simple type plus null (for example: type: [string, null])" | ||
) | ||
self._fail_on_errors(errors) | ||
|
||
def test_object_not_empty(self, connector_specification: dict): | ||
""" | ||
Each object field needs to have at least one property as the UI won't be able to show them otherwise. | ||
If the whole spec is empty, it's allowed to have a single empty object at the top level | ||
flash1293 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
schema_helper = JsonSchemaHelper(connector_specification) | ||
errors = [] | ||
for type_path, type_value in dpath.util.search(connector_specification, "**/type", yielded=True): | ||
if type_path == "type": | ||
# allow empty root object | ||
continue | ||
if type_value == "object": | ||
property = schema_helper.get_parent(type_path) | ||
if "oneOf" not in property and ("properties" not in property or len(property["properties"]) == 0): | ||
errors.append( | ||
f"{type_path} is an empty object which will not be represented correctly in the UI. Either remove or add specific properties" | ||
) | ||
self._fail_on_errors(errors) | ||
|
||
def test_array_type(self, connector_specification: dict): | ||
""" | ||
Each array has one or multiple types for its items, but the UI only supports a single type which can either be object, string or an enum | ||
""" | ||
schema_helper = JsonSchemaHelper(connector_specification) | ||
errors = [] | ||
for type_path, type_type in dpath.util.search(connector_specification, "**/type", yielded=True): | ||
property_definition = schema_helper.get_parent(type_path) | ||
if type_type != "array": | ||
# unrelated "items", not an array definition | ||
continue | ||
items_value = property_definition.get("items", None) | ||
if items_value is None: | ||
continue | ||
flash1293 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
elif isinstance(items_value, List): | ||
errors.append(f"{type_path} is not just a single item type: {items_value}") | ||
elif items_value.get("type") not in ["object", "string", "number", "integer"] and "enum" not in items_value: | ||
errors.append(f"Items of {type_path} has to be either object or string or define an enum") | ||
self._fail_on_errors(errors) | ||
|
||
def test_forbidden_complex_types(self, connector_specification: dict): | ||
""" | ||
not, anyOf, patternProperties, prefixItems, allOf, if, then, else, dependentSchemas and dependentRequired are not allowed | ||
""" | ||
forbidden_keys = [ | ||
"not", | ||
"anyOf", | ||
"patternProperties", | ||
"prefixItems", | ||
"allOf", | ||
"if", | ||
"then", | ||
"else", | ||
"dependentSchemas", | ||
"dependentRequired", | ||
] | ||
found_keys = set() | ||
for forbidden_key in forbidden_keys: | ||
for path, value in dpath.util.search(connector_specification, f"**/{forbidden_key}", yielded=True): | ||
found_keys.add(path) | ||
|
||
for forbidden_key in forbidden_keys: | ||
# remove forbidden keys if they are used as properties directly | ||
for path, _value in dpath.util.search(connector_specification, f"**/properties/{forbidden_key}", yielded=True): | ||
found_keys.remove(path) | ||
|
||
if len(found_keys) > 0: | ||
key_list = ", ".join(found_keys) | ||
pytest.fail(f"Found the following disallowed JSON schema features: {key_list}") | ||
|
||
def test_date_pattern(self, connector_specification: dict, detailed_logger): | ||
""" | ||
Properties with format date or date-time should always have a pattern defined how the date/date-time should be formatted | ||
that corresponds with the format the datepicker component is creating. | ||
""" | ||
schema_helper = JsonSchemaHelper(connector_specification) | ||
for format_path, format in dpath.util.search(connector_specification, "**/format", yielded=True): | ||
if not isinstance(format, str): | ||
# format is not a format definition here but a property named format | ||
continue | ||
flash1293 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
property_definition = schema_helper.get_parent(format_path) | ||
pattern = property_definition.get("pattern") | ||
if format == "date" and not pattern == DATE_PATTERN: | ||
detailed_logger.warning( | ||
f"{format_path} is defining a date format without the corresponding pattern. Consider setting the pattern to {DATE_PATTERN} to make it easier for users to edit this field in the UI." | ||
) | ||
if format == "date-time" and not pattern == DATETIME_PATTERN: | ||
detailed_logger.warning( | ||
f"{format_path} is defining a date-time format without the corresponding pattern Consider setting the pattern to {DATETIME_PATTERN} to make it easier for users to edit this field in the UI." | ||
) | ||
|
||
def test_date_format(self, connector_specification: dict, detailed_logger): | ||
""" | ||
Properties with a pattern that looks like a date should have their format set to date or date-time. | ||
""" | ||
schema_helper = JsonSchemaHelper(connector_specification) | ||
for pattern_path, pattern in dpath.util.search(connector_specification, "**/pattern", yielded=True): | ||
if not isinstance(pattern, str): | ||
# pattern is not a pattern definition here but a property named pattern | ||
continue | ||
if pattern == DATE_PATTERN or pattern == DATETIME_PATTERN: | ||
property_definition = schema_helper.get_parent(pattern_path) | ||
format = property_definition.get("format") | ||
if not format == "date" and pattern == DATE_PATTERN: | ||
detailed_logger.warning( | ||
f"{pattern_path} is defining a pattern that looks like a date without setting the format to `date`. Consider specifying the format to make it easier for users to edit this field in the UI." | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this fail? What would be a valid format that would trigger a warning? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was on the fence about this, too - I decided this way because of the following reasoning:
This is definitely not a strong reasoning, I will put it in the issue I mentioned above about the check the other way. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to @flash1293's reasoning - I had similar reasoning here re: we can't enforce date-formatting on things that just look like they could be dates - its up to the source to define whether we should interpret it as a date or not |
||
) | ||
if not format == "date-time" and pattern == DATETIME_PATTERN: | ||
detailed_logger.warning( | ||
f"{pattern_path} is defining a pattern that looks like a date-time without setting the format to `date-time`. Consider specifying the format to make it easier for users to edit this field in the UI." | ||
) | ||
|
||
def test_defined_refs_exist_in_json_spec_file(self, connector_spec_dict: dict): | ||
"""Checking for the presence of unresolved `$ref`s values within each json spec file""" | ||
check_result = list(find_all_values_for_key_in_schema(connector_spec_dict, "$ref")) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<3