From 64625ee92120868e2c874f1f149c4fb89b0a4d2f Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Wed, 16 Mar 2022 18:02:01 -0700 Subject: [PATCH] Add unit tests. --- superset/initialization/__init__.py | 2 +- .../{import_export => importexport}/api.py | 43 ++++++- tests/unit_tests/conftest.py | 29 +++-- tests/unit_tests/views/__init__.py | 16 +++ .../unit_tests/views/importexport/__init__.py | 16 +++ .../unit_tests/views/importexport/api_test.py | 116 ++++++++++++++++++ 6 files changed, 208 insertions(+), 14 deletions(-) rename superset/views/{import_export => importexport}/api.py (72%) create mode 100644 tests/unit_tests/views/__init__.py create mode 100644 tests/unit_tests/views/importexport/__init__.py create mode 100644 tests/unit_tests/views/importexport/api_test.py diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index a783c00e7fa69..0be0c3a985b95 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -180,7 +180,7 @@ def init_views(self) -> None: ) from superset.views.datasource.views import Datasource from superset.views.dynamic_plugins import DynamicPluginsView - from superset.views.import_export.api import ImportExportRestApi + from superset.views.importexport.api import ImportExportRestApi from superset.views.key_value import KV from superset.views.log.api import LogRestApi from superset.views.log.views import LogModelView diff --git a/superset/views/import_export/api.py b/superset/views/importexport/api.py similarity index 72% rename from superset/views/import_export/api.py rename to superset/views/importexport/api.py index 5d3f877caa0b8..efd885b4d825c 100644 --- a/superset/views/import_export/api.py +++ b/superset/views/importexport/api.py @@ -70,7 +70,7 @@ def export(self) -> Response: $ref: '#/components/responses/500' """ timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") - root = f"superset_export_{timestamp}" + root = f"assets_export_{timestamp}" filename = f"{root}.zip" buf = BytesIO() @@ -96,6 +96,47 @@ def export(self) -> Response: ) @requires_form_data def import_(self) -> Response: + """Import multiple assets + --- + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + bundle: + description: upload file (ZIP or JSON) + type: string + format: binary + passwords: + description: >- + JSON map of passwords for each featured database in the + ZIP file. If the ZIP includes a database config in the path + `databases/MyDatabase.yaml`, the password should be provided + in the following format: + `{"databases/MyDatabase.yaml": "my_password"}`. + type: string + responses: + 200: + description: Dashboard import result + content: + application/json: + schema: + type: object + properties: + message: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ upload = request.files.get("bundle") if not upload: return self.response_400() diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 8522877c28865..ccd4f3485db2a 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -16,7 +16,7 @@ # under the License. # pylint: disable=redefined-outer-name -from typing import Iterator +from typing import Any, Iterator import pytest from pytest_mock import MockFixture @@ -28,8 +28,8 @@ from superset.initialization import SupersetAppInitializer -@pytest.fixture() -def session() -> Iterator[Session]: +@pytest.fixture +def session(mocker: MockFixture) -> Iterator[Session]: """ Create an in-memory SQLite session to test models. """ @@ -40,11 +40,18 @@ def session() -> Iterator[Session]: # flask calls session.remove() in_memory_session.remove = lambda: None + # patch session + mocker.patch( + "superset.security.SupersetSecurityManager.get_session", + return_value=in_memory_session, + ) + mocker.patch("superset.db.session", in_memory_session) + yield in_memory_session -@pytest.fixture -def app(mocker: MockFixture, session: Session) -> Iterator[SupersetApp]: +@pytest.fixture(scope="session") +def app() -> Iterator[SupersetApp]: """ A fixture that generates a Superset app. """ @@ -52,20 +59,18 @@ def app(mocker: MockFixture, session: Session) -> Iterator[SupersetApp]: app.config.from_object("superset.config") app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" - app.config["FAB_ADD_SECURITY_VIEWS"] = False app_initializer = app.config.get("APP_INITIALIZER", SupersetAppInitializer)(app) app_initializer.init_app() - # patch session - mocker.patch( - "superset.security.SupersetSecurityManager.get_session", return_value=session, - ) - mocker.patch("superset.db.session", session) - yield app +@pytest.fixture +def client(app: SupersetApp) -> Any: + return app.test_client() + + @pytest.fixture def app_context(app: SupersetApp) -> Iterator[None]: """ diff --git a/tests/unit_tests/views/__init__.py b/tests/unit_tests/views/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/unit_tests/views/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License 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. diff --git a/tests/unit_tests/views/importexport/__init__.py b/tests/unit_tests/views/importexport/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/unit_tests/views/importexport/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License 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. diff --git a/tests/unit_tests/views/importexport/api_test.py b/tests/unit_tests/views/importexport/api_test.py new file mode 100644 index 0000000000000..7127fdf5e3a76 --- /dev/null +++ b/tests/unit_tests/views/importexport/api_test.py @@ -0,0 +1,116 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License 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. + +import json +from io import BytesIO +from typing import Any +from zipfile import is_zipfile, ZipFile + +from pytest_mock import MockFixture + +from superset import security_manager + + +def test_export_assets(mocker: MockFixture, client: Any) -> None: + """ + Test exporting assets. + """ + # pylint: disable=import-outside-toplevel + from superset.commands.importers.v1.utils import get_contents_from_bundle + + # grant access + mocker.patch( + "flask_appbuilder.security.decorators.verify_jwt_in_request", return_value=True + ) + mocker.patch.object(security_manager, "has_access", return_value=True) + + # pylint: disable=invalid-name + ExportAssetsCommand = mocker.patch( + "superset.views.importexport.api.ExportAssetsCommand" + ) + ExportAssetsCommand().run.return_value = [ + ( + "metadata.yaml", + "version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n", + ), + ("databases/example.yaml", ""), + ] + + response = client.get("/api/v1/assets/export/") + assert response.status_code == 200 + + buf = BytesIO(response.data) + assert is_zipfile(buf) + + buf.seek(0) + with ZipFile(buf) as bundle: + contents = get_contents_from_bundle(bundle) + assert contents == { + "metadata.yaml": ( + "version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n" + ), + "databases/example.yaml": "", + } + + +def test_import_assets(mocker: MockFixture, client: Any) -> None: + """ + Test importing assets. + """ + # grant access + mocker.patch( + "flask_appbuilder.security.decorators.verify_jwt_in_request", return_value=True + ) + mocker.patch.object(security_manager, "has_access", return_value=True) + + # pylint: disable=invalid-name + ImportAssetsCommand = mocker.patch( + "superset.views.importexport.api.ImportAssetsCommand" + ) + + buf = BytesIO() + with ZipFile(buf, "w") as bundle: + with bundle.open("assets_export/metadata.yaml", "w") as fp: + fp.write( + "version: 1.0.0\n" + "type: assets\n" + "timestamp: '2022-01-01T00:00:00+00:00'\n".encode() + ) + with bundle.open("assets_export/databases/example.yaml", "w") as fp: + fp.write("".encode()) + buf.seek(0) + + form_data = { + "bundle": (buf, "assets_export.zip"), + "passwords": json.dumps( + {"assets_export/databases/imported_database.yaml": "SECRET"} + ), + } + response = client.post( + "/api/v1/assets/import/", data=form_data, content_type="multipart/form-data" + ) + assert response.status_code == 200 + assert response.json == {"message": "OK"} + + contents = { + "metadata.yaml": ( + "version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n" + ), + "databases/example.yaml": "", + } + passwords = {"assets_export/databases/imported_database.yaml": "SECRET"} + ImportAssetsCommand.assert_called_with(contents, passwords=passwords)