This is an automated email from the ASF dual-hosted git repository.
beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new 2b9695c feat: add modal to import databases (#11884)
2b9695c is described below
commit 2b9695c52024b624631ac27c0f2194e280effdb8
Author: Beto Dealmeida <[email protected]>
AuthorDate: Mon Dec 7 11:22:45 2020 -0800
feat: add modal to import databases (#11884)
* feat: add modal to import databases
* Fix test
* Improve hook
* Remove log and needless store.
* Change JS functions
---
.../components/ImportModal/ImportModal.test.tsx | 96 +++++++++++
.../src/database/components/ImportModal/index.tsx | 191 +++++++++++++++++++++
.../src/views/CRUD/data/database/DatabaseList.tsx | 34 ++++
.../src/views/CRUD/data/database/DatabaseModal.tsx | 4 +-
superset-frontend/src/views/CRUD/hooks.ts | 92 ++++++++++
superset/databases/api.py | 9 +-
.../databases/commands/importers/dispatcher.py | 4 +-
.../databases/commands/importers/v1/__init__.py | 8 +-
superset/databases/schemas.py | 15 +-
superset/models/core.py | 1 +
superset/models/helpers.py | 11 +-
tests/databases/api_tests.py | 97 ++++++++++-
tests/databases/commands_tests.py | 20 +++
13 files changed, 569 insertions(+), 13 deletions(-)
diff --git
a/superset-frontend/src/database/components/ImportModal/ImportModal.test.tsx
b/superset-frontend/src/database/components/ImportModal/ImportModal.test.tsx
new file mode 100644
index 0000000..acad00c
--- /dev/null
+++ b/superset-frontend/src/database/components/ImportModal/ImportModal.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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 React from 'react';
+import { styledMount as mount } from 'spec/helpers/theming';
+import { ReactWrapper } from 'enzyme';
+
+import ImportDatabaseModal from 'src/database/components/ImportModal';
+import Modal from 'src/common/components/Modal';
+
+const requiredProps = {
+ addDangerToast: () => {},
+ addSuccessToast: () => {},
+ onDatabaseImport: () => {},
+ show: true,
+ onHide: () => {},
+};
+
+describe('ImportDatabaseModal', () => {
+ let wrapper: ReactWrapper;
+
+ beforeEach(() => {
+ wrapper = mount(<ImportDatabaseModal {...requiredProps} />);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders', () => {
+ expect(wrapper.find(ImportDatabaseModal)).toExist();
+ });
+
+ it('renders a Modal', () => {
+ expect(wrapper.find(Modal)).toExist();
+ });
+
+ it('renders "Import Database" header', () => {
+ expect(wrapper.find('h4').text()).toEqual('Import Database');
+ });
+
+ it('renders a label and a file input field', () => {
+ expect(wrapper.find('input[type="file"]')).toExist();
+ expect(wrapper.find('label')).toExist();
+ });
+
+ it('should attach the label to the input field', () => {
+ const id = 'databaseFile';
+ expect(wrapper.find('label').prop('htmlFor')).toBe(id);
+ expect(wrapper.find('input').prop('id')).toBe(id);
+ });
+
+ it('should render the close, import and cancel buttons', () => {
+ expect(wrapper.find('button')).toHaveLength(3);
+ });
+
+ it('should render the import button initially disabled', () => {
+ expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
+ true,
+ );
+ });
+
+ it('should render the import button enabled when a file is selected', () => {
+ const file = new File([new ArrayBuffer(1)], 'database_export.zip');
+ wrapper.find('input').simulate('change', { target: { files: [file] } });
+
+ expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
+ false,
+ );
+ });
+
+ it('should render password fields when needed for import', () => {
+ const wrapperWithPasswords = mount(
+ <ImportDatabaseModal
+ {...requiredProps}
+ passwordFields={['databases/examples.yaml']}
+ />,
+ );
+ expect(wrapperWithPasswords.find('input[type="password"]')).toExist();
+ });
+});
diff --git a/superset-frontend/src/database/components/ImportModal/index.tsx
b/superset-frontend/src/database/components/ImportModal/index.tsx
new file mode 100644
index 0000000..35234d2
--- /dev/null
+++ b/superset-frontend/src/database/components/ImportModal/index.tsx
@@ -0,0 +1,191 @@
+/**
+ * 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 React, { FunctionComponent, useEffect, useRef, useState } from 'react';
+import { t } from '@superset-ui/core';
+
+import Modal from 'src/common/components/Modal';
+import {
+ StyledIcon,
+ StyledInputContainer,
+} from 'src/views/CRUD/data/database/DatabaseModal';
+import { useImportResource } from 'src/views/CRUD/hooks';
+import { DatabaseObject } from 'src/views/CRUD/data/database/types';
+
+export interface ImportDatabaseModalProps {
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+ onDatabaseImport: () => void;
+ show: boolean;
+ onHide: () => void;
+ passwordFields?: string[];
+ setPasswordFields?: (passwordFields: string[]) => void;
+}
+
+const ImportDatabaseModal: FunctionComponent<ImportDatabaseModalProps> = ({
+ addDangerToast,
+ addSuccessToast,
+ onDatabaseImport,
+ show,
+ onHide,
+ passwordFields = [],
+ setPasswordFields = () => {},
+}) => {
+ const [uploadFile, setUploadFile] = useState<File | null>(null);
+ const [isHidden, setIsHidden] = useState<boolean>(true);
+ const [passwords, setPasswords] = useState<Record<string, string>>({});
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ const clearModal = () => {
+ setUploadFile(null);
+ setPasswords({});
+ setPasswordFields([]);
+ if (fileInputRef && fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const handleErrorMsg = (msg: string) => {
+ clearModal();
+ addDangerToast(msg);
+ };
+
+ const {
+ state: { passwordsNeeded },
+ importResource,
+ } = useImportResource<DatabaseObject>(
+ 'database',
+ t('database'),
+ handleErrorMsg,
+ );
+
+ useEffect(() => {
+ setPasswordFields(passwordsNeeded);
+ }, [passwordsNeeded]);
+
+ // Functions
+ const hide = () => {
+ setIsHidden(true);
+ onHide();
+ };
+
+ const onUpload = () => {
+ if (uploadFile === null) {
+ return;
+ }
+
+ importResource(uploadFile, passwords).then(result => {
+ if (result) {
+ addSuccessToast(t('The databases have been imported'));
+ clearModal();
+ onDatabaseImport();
+ }
+ });
+ };
+
+ const changeFile = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const { files } = event.target as HTMLInputElement;
+ setUploadFile((files && files[0]) || null);
+ };
+
+ const renderPasswordFields = () => {
+ if (passwordFields.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ <h5>Passwords</h5>
+ <StyledInputContainer>
+ <div className="helper">
+ {t('Please provide the password for the databases below')}
+ </div>
+ </StyledInputContainer>
+ {passwordFields.map(fileName => (
+ <StyledInputContainer key={`password-for-${fileName}`}>
+ <div className="control-label">
+ {fileName}
+ <span className="required">*</span>
+ </div>
+ <input
+ name={`password-${fileName}`}
+ autoComplete="off"
+ type="password"
+ value={passwords[fileName]}
+ onChange={event =>
+ setPasswords({ ...passwords, [fileName]: event.target.value })
+ }
+ />
+ </StyledInputContainer>
+ ))}
+ </>
+ );
+ };
+
+ // Show/hide
+ if (isHidden && show) {
+ setIsHidden(false);
+ }
+
+ return (
+ <Modal
+ name="database"
+ className="database-modal"
+ disablePrimaryButton={uploadFile === null}
+ onHandledPrimaryAction={onUpload}
+ onHide={hide}
+ primaryButtonName={t('Import')}
+ width="750px"
+ show={show}
+ title={
+ <h4>
+ <StyledIcon name="database" />
+ {t('Import Database')}
+ </h4>
+ }
+ >
+ <StyledInputContainer>
+ <div className="control-label">
+ <label htmlFor="databaseFile">
+ {t('File')}
+ <span className="required">*</span>
+ </label>
+ </div>
+ <input
+ ref={fileInputRef}
+ data-test="database-file-input"
+ name="databaseFile"
+ id="databaseFile"
+ type="file"
+ accept=".yaml,.json,.yml,.zip"
+ onChange={changeFile}
+ />
+ <div className="helper">
+ {t(
+ 'Please note that the "Secure Extra" and "Certificate" sections of
' +
+ 'the database configuration are not present in export files, and
' +
+ 'should be added manually after the import if they are needed.',
+ )}
+ </div>
+ </StyledInputContainer>
+ {renderPasswordFields()}
+ </Modal>
+ );
+};
+
+export default ImportDatabaseModal;
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index db9252c..4b0c3f8 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -29,6 +29,7 @@ import TooltipWrapper from 'src/components/TooltipWrapper';
import Icon from 'src/components/Icon';
import ListView, { Filters } from 'src/components/ListView';
import { commonMenuData } from 'src/views/CRUD/data/common';
+import ImportDatabaseModal from 'src/database/components/ImportModal/index';
import DatabaseModal from './DatabaseModal';
import { DatabaseObject } from './types';
@@ -74,6 +75,21 @@ function DatabaseList({ addDangerToast, addSuccessToast }:
DatabaseListProps) {
const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject |
null>(
null,
);
+ const [importingDatabase, showImportModal] = useState<boolean>(false);
+ const [passwordFields, setPasswordFields] = useState<string[]>([]);
+
+ const openDatabaseImportModal = () => {
+ showImportModal(true);
+ };
+
+ const closeDatabaseImportModal = () => {
+ showImportModal(false);
+ };
+
+ const handleDatabaseImport = () => {
+ showImportModal(false);
+ refreshData();
+ };
const openDatabaseDeleteModal = (database: DatabaseObject) =>
SupersetClient.get({
@@ -146,6 +162,14 @@ function DatabaseList({ addDangerToast, addSuccessToast }:
DatabaseListProps) {
},
},
];
+
+ if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) {
+ menuData.buttons.push({
+ name: <Icon name="import" />,
+ buttonStyle: 'link',
+ onClick: openDatabaseImportModal,
+ });
+ }
}
function handleDatabaseExport(database: DatabaseObject) {
@@ -400,6 +424,16 @@ function DatabaseList({ addDangerToast, addSuccessToast }:
DatabaseListProps) {
loading={loading}
pageSize={PAGE_SIZE}
/>
+
+ <ImportDatabaseModal
+ show={importingDatabase}
+ onHide={closeDatabaseImportModal}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ onDatabaseImport={handleDatabaseImport}
+ passwordFields={passwordFields}
+ setPasswordFields={setPasswordFields}
+ />
</>
);
}
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
index fa8a362..69fa99b 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
@@ -39,11 +39,11 @@ interface DatabaseModalProps {
database?: DatabaseObject | null; // If included, will go into edit mode
}
-const StyledIcon = styled(Icon)`
+export const StyledIcon = styled(Icon)`
margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
`;
-const StyledInputContainer = styled.div`
+export const StyledInputContainer = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
&.extra-container {
diff --git a/superset-frontend/src/views/CRUD/hooks.ts
b/superset-frontend/src/views/CRUD/hooks.ts
index 0132615..e209815 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -25,6 +25,7 @@ import { FetchDataConfig } from 'src/components/ListView';
import { FilterValue } from 'src/components/ListView/types';
import Chart, { Slice } from 'src/types/Chart';
import copyTextToClipboard from 'src/utils/copy';
+import getClientErrorObject from 'src/utils/getClientErrorObject';
import { FavoriteStatus } from './types';
interface ListViewResourceState<D extends object = any> {
@@ -312,6 +313,97 @@ export function useSingleViewResource<D extends object =
any>(
};
}
+interface ImportResourceState<D extends object = any> {
+ loading: boolean;
+ passwordsNeeded: string[];
+}
+
+export function useImportResource<D extends object = any>(
+ resourceName: string,
+ resourceLabel: string, // resourceLabel for translations
+ handleErrorMsg: (errorMsg: string) => void,
+) {
+ const [state, setState] = useState<ImportResourceState<D>>({
+ loading: false,
+ passwordsNeeded: [],
+ });
+
+ function updateState(update: Partial<ImportResourceState<D>>) {
+ setState(currentState => ({ ...currentState, ...update }));
+ }
+
+ const needsPassword = (errMsg: Record<string, Record<string, string[]>>) =>
+ Object.values(errMsg).every(validationErrors =>
+ Object.entries(validationErrors as Object).every(
+ ([field, messages]) =>
+ field === '_schema' &&
+ messages.length === 1 &&
+ messages[0] === 'Must provide a password for the database',
+ ),
+ );
+
+ const importResource = useCallback(
+ (bundle: File, databasePasswords: Record<string, string> = {}) => {
+ // Set loading state
+ updateState({
+ loading: true,
+ });
+
+ const formData = new FormData();
+ formData.append('formData', bundle);
+
+ /* The import bundle never contains database passwords; if required
+ * they should be provided by the user during import.
+ */
+ if (databasePasswords) {
+ formData.append('passwords', JSON.stringify(databasePasswords));
+ }
+
+ return SupersetClient.post({
+ endpoint: `/api/v1/${resourceName}/import/`,
+ body: formData,
+ })
+ .then(() => true)
+ .catch(response =>
+ getClientErrorObject(response).then(error => {
+ /* When importing a bundle, if all validation errors are because
+ * the databases need passwords we return a list of the database
+ * files so that the user can type in the passwords and resubmit
+ * the file.
+ */
+ const errMsg = error.message || error.error;
+ if (typeof errMsg !== 'string' && needsPassword(errMsg)) {
+ updateState({
+ passwordsNeeded: Object.keys(errMsg),
+ });
+ return false;
+ }
+ handleErrorMsg(
+ t(
+ 'An error occurred while importing %s: %s',
+ resourceLabel,
+ JSON.stringify(errMsg),
+ ),
+ );
+ return false;
+ }),
+ )
+ .finally(() => {
+ updateState({ loading: false });
+ });
+ },
+ [],
+ );
+
+ return {
+ state: {
+ loading: state.loading,
+ passwordsNeeded: state.passwordsNeeded,
+ },
+ importResource,
+ };
+}
+
enum FavStarClassName {
CHART = 'slice',
DASHBOARD = 'Dashboard',
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 892d3ef..707c9ae 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import json
import logging
from datetime import datetime
from io import BytesIO
@@ -776,7 +777,13 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
for file_name in bundle.namelist()
}
- command = ImportDatabasesCommand(contents)
+ passwords = (
+ json.loads(request.form["passwords"])
+ if "passwords" in request.form
+ else None
+ )
+
+ command = ImportDatabasesCommand(contents, passwords=passwords)
try:
command.run()
return self.response(200, message="OK")
diff --git a/superset/databases/commands/importers/dispatcher.py
b/superset/databases/commands/importers/dispatcher.py
index fd02359..0f9cd1a 100644
--- a/superset/databases/commands/importers/dispatcher.py
+++ b/superset/databases/commands/importers/dispatcher.py
@@ -41,12 +41,14 @@ class ImportDatabasesCommand(BaseCommand):
# pylint: disable=unused-argument
def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any):
self.contents = contents
+ self.args = args
+ self.kwargs = kwargs
def run(self) -> None:
# iterate over all commands until we find a version that can
# handle the contents
for version in command_versions:
- command = version(self.contents)
+ command = version(self.contents, *self.args, **self.kwargs)
try:
command.run()
return
diff --git a/superset/databases/commands/importers/v1/__init__.py
b/superset/databases/commands/importers/v1/__init__.py
index c51ce3e..6d16649 100644
--- a/superset/databases/commands/importers/v1/__init__.py
+++ b/superset/databases/commands/importers/v1/__init__.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import urllib.parse
from typing import Any, Dict, List, Optional
from marshmallow import Schema, validate
@@ -47,8 +48,11 @@ class ImportDatabasesCommand(BaseCommand):
"""Import databases"""
# pylint: disable=unused-argument
- def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any):
+ def __init__(
+ self, contents: Dict[str, str], *args: Any, **kwargs: Any,
+ ):
self.contents = contents
+ self.passwords = kwargs.get("passwords") or {}
self._configs: Dict[str, Any] = {}
def _import_bundle(self, session: Session) -> None:
@@ -96,6 +100,8 @@ class ImportDatabasesCommand(BaseCommand):
if schema:
try:
config = load_yaml(file_name, content)
+ if file_name in self.passwords:
+ config["password"] = self.passwords[file_name]
schema.load(config)
self._configs[file_name] = config
except ValidationError as exc:
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index d5e9ba3..b6d6a7a 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -16,16 +16,19 @@
# under the License.
import inspect
import json
+import urllib.parse
+from typing import Any, Dict
from flask import current_app
from flask_babel import lazy_gettext as _
-from marshmallow import fields, Schema
+from marshmallow import fields, Schema, validates_schema
from marshmallow.validate import Length, ValidationError
from sqlalchemy import MetaData
from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import ArgumentError
from superset.exceptions import CertificateException
+from superset.models.core import PASSWORD_MASK
from superset.utils.core import markdown, parse_ssl_cert
database_schemas_query_schema = {
@@ -420,6 +423,7 @@ class ImportV1DatabaseExtraSchema(Schema):
class ImportV1DatabaseSchema(Schema):
database_name = fields.String(required=True)
sqlalchemy_uri = fields.String(required=True)
+ password = fields.String(allow_none=True)
cache_timeout = fields.Integer(allow_none=True)
expose_in_sqllab = fields.Boolean()
allow_run_async = fields.Boolean()
@@ -429,3 +433,12 @@ class ImportV1DatabaseSchema(Schema):
extra = fields.Nested(ImportV1DatabaseExtraSchema)
uuid = fields.UUID(required=True)
version = fields.String(required=True)
+
+ # pylint: disable=no-self-use, unused-argument
+ @validates_schema
+ def validate_password(self, data: Dict[str, Any], **kwargs: Any) -> None:
+ """If sqlalchemy_uri has a masked password, password is required"""
+ uri = data["sqlalchemy_uri"]
+ password = urllib.parse.urlparse(uri).password
+ if password == PASSWORD_MASK and data.get("password") is None:
+ raise ValidationError("Must provide a password for the database")
diff --git a/superset/models/core.py b/superset/models/core.py
index c4a8369..6180840 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -155,6 +155,7 @@ class Database(
"allow_csv_upload",
"extra",
]
+ extra_import_fields = ["password"]
export_children = ["tables"]
def __repr__(self) -> str:
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 623bb07..dddfa8e 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -85,6 +85,10 @@ class ImportExportMixin:
# The names of the attributes
# that are available for import and export
+ extra_import_fields: List[str] = []
+ # Additional fields that should be imported,
+ # even though they were not exported
+
__mapper__: Mapper
@classmethod
@@ -155,7 +159,12 @@ class ImportExportMixin:
if sync is None:
sync = []
parent_refs = cls.parent_foreign_key_mappings()
- export_fields = set(cls.export_fields) | set(parent_refs.keys()) |
{"uuid"}
+ export_fields = (
+ set(cls.export_fields)
+ | set(cls.extra_import_fields)
+ | set(parent_refs.keys())
+ | {"uuid"}
+ )
new_children = {c: dict_rep[c] for c in cls.export_children if c in
dict_rep}
unique_constrains = cls._unique_constrains()
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index 6e2dbef..71c96b2 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -158,7 +158,7 @@ class TestDatabaseApi(SupersetTestCase):
Database API: Test get items not allowed
"""
self.login(username="gamma")
- uri = f"api/v1/database/"
+ uri = "api/v1/database/"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
@@ -451,7 +451,7 @@ class TestDatabaseApi(SupersetTestCase):
"""
self.login(username="admin")
database_data = {"database_name": "test-database-updated"}
- uri = f"api/v1/database/invalid"
+ uri = "api/v1/database/invalid"
rv = self.client.put(uri, json=database_data)
self.assertEqual(rv.status_code, 404)
@@ -556,7 +556,7 @@ class TestDatabaseApi(SupersetTestCase):
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 404)
- uri = f"api/v1/database/some_database/table/some_table/some_schema/"
+ uri = "api/v1/database/some_database/table/some_table/some_schema/"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 404)
@@ -718,7 +718,7 @@ class TestDatabaseApi(SupersetTestCase):
"sqlalchemy_uri": example_db.safe_sqlalchemy_uri(),
"server_cert": ssl_certificate,
}
- url = f"api/v1/database/test_connection"
+ url = "api/v1/database/test_connection"
rv = self.post_assert_metric(url, data, "test_connection")
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.headers["Content-Type"], "application/json;
charset=utf-8")
@@ -747,7 +747,7 @@ class TestDatabaseApi(SupersetTestCase):
"impersonate_user": False,
"server_cert": None,
}
- url = f"api/v1/database/test_connection"
+ url = "api/v1/database/test_connection"
rv = self.post_assert_metric(url, data, "test_connection")
self.assertEqual(rv.status_code, 400)
self.assertEqual(rv.headers["Content-Type"], "application/json;
charset=utf-8")
@@ -787,7 +787,7 @@ class TestDatabaseApi(SupersetTestCase):
"impersonate_user": False,
"server_cert": None,
}
- url = f"api/v1/database/test_connection"
+ url = "api/v1/database/test_connection"
rv = self.post_assert_metric(url, data, "test_connection")
self.assertEqual(rv.status_code, 400)
response = json.loads(rv.data.decode("utf-8"))
@@ -947,3 +947,88 @@ class TestDatabaseApi(SupersetTestCase):
assert response == {
"message": {"metadata.yaml": {"type": ["Must be equal to
Database."]}}
}
+
+ def test_import_database_masked_password(self):
+ """
+ Database API: Test import database with masked password
+ """
+ self.login(username="admin")
+ uri = "api/v1/database/import/"
+
+ masked_database_config = database_config.copy()
+ masked_database_config[
+ "sqlalchemy_uri"
+ ] = "postgresql://username:XXXXXXXXXX@host:12345/db"
+
+ buf = BytesIO()
+ with ZipFile(buf, "w") as bundle:
+ with bundle.open("database_export/metadata.yaml", "w") as fp:
+ fp.write(yaml.safe_dump(database_metadata_config).encode())
+ with bundle.open(
+ "database_export/databases/imported_database.yaml", "w"
+ ) as fp:
+ fp.write(yaml.safe_dump(masked_database_config).encode())
+ with bundle.open(
+ "database_export/datasets/imported_dataset.yaml", "w"
+ ) as fp:
+ fp.write(yaml.safe_dump(dataset_config).encode())
+ buf.seek(0)
+
+ form_data = {
+ "formData": (buf, "database_export.zip"),
+ }
+ rv = self.client.post(uri, data=form_data,
content_type="multipart/form-data")
+ response = json.loads(rv.data.decode("utf-8"))
+
+ assert rv.status_code == 422
+ assert response == {
+ "message": {
+ "databases/imported_database.yaml": {
+ "_schema": ["Must provide a password for the database"]
+ }
+ }
+ }
+
+ def test_import_database_masked_password_provided(self):
+ """
+ Database API: Test import database with masked password provided
+ """
+ self.login(username="admin")
+ uri = "api/v1/database/import/"
+
+ masked_database_config = database_config.copy()
+ masked_database_config[
+ "sqlalchemy_uri"
+ ] = "postgresql://username:XXXXXXXXXX@host:12345/db"
+
+ buf = BytesIO()
+ with ZipFile(buf, "w") as bundle:
+ with bundle.open("database_export/metadata.yaml", "w") as fp:
+ fp.write(yaml.safe_dump(database_metadata_config).encode())
+ with bundle.open(
+ "database_export/databases/imported_database.yaml", "w"
+ ) as fp:
+ fp.write(yaml.safe_dump(masked_database_config).encode())
+ buf.seek(0)
+
+ form_data = {
+ "formData": (buf, "database_export.zip"),
+ "passwords": json.dumps({"databases/imported_database.yaml":
"SECRET"}),
+ }
+ rv = self.client.post(uri, data=form_data,
content_type="multipart/form-data")
+ response = json.loads(rv.data.decode("utf-8"))
+
+ assert rv.status_code == 200
+ assert response == {"message": "OK"}
+
+ database = (
+
db.session.query(Database).filter_by(uuid=database_config["uuid"]).one()
+ )
+ assert database.database_name == "imported_database"
+ assert (
+ database.sqlalchemy_uri ==
"postgresql://username:XXXXXXXXXX@host:12345/db"
+ )
+ assert database.password == "SECRET"
+
+ db.session.delete(database)
+ db.session.commit()
diff --git a/tests/databases/commands_tests.py
b/tests/databases/commands_tests.py
index 6b9247d..3ace131 100644
--- a/tests/databases/commands_tests.py
+++ b/tests/databases/commands_tests.py
@@ -452,6 +452,26 @@ class TestImportDatabasesCommand(SupersetTestCase):
}
}
+ def test_import_v1_database_masked_password(self):
+ """Test that database imports with masked passwords are rejected"""
+ masked_database_config = database_config.copy()
+ masked_database_config[
+ "sqlalchemy_uri"
+ ] = "postgresql://username:XXXXXXXXXX@host:12345/db"
+ contents = {
+ "metadata.yaml": yaml.safe_dump(database_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(masked_database_config),
+ }
+ command = ImportDatabasesCommand(contents)
+ with pytest.raises(CommandInvalidError) as excinfo:
+ command.run()
+ assert str(excinfo.value) == "Error importing database"
+ assert excinfo.value.normalized_messages() == {
+ "databases/imported_database.yaml": {
+ "_schema": ["Must provide a password for the database"]
+ }
+ }
+
@patch("superset.databases.commands.importers.v1.import_dataset")
def test_import_v1_rollback(self, mock_import_dataset):
"""Test than on an exception everything is rolled back"""