Compare commits

...

38 Commits

Author SHA1 Message Date
Enzo Martellucci
7136fb22ba Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-08 09:13:32 +02:00
Enzo Martellucci
36126d9e2f Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-06 01:46:38 +02:00
Enzo Martellucci
82189e06e3 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-04 22:04:32 +02:00
Enzo Martellucci
f35638967c fix: lint 2026-06-04 15:13:06 +02:00
Enzo Martellucci
cba0b99d20 Merge remote-tracking branch 'origin/master' into enxdev/feat/enhance-database-modal-validation
# Conflicts:
#	superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx
2026-06-04 14:34:54 +02:00
Enzo Martellucci
5abdd4bceb fix(database): tighten validation result handling at save and SSH gating
- Treat `errors === null` from getValidation as blocking during save;
  the previous isEmpty check let unexpected/stale responses slip through
  into create/update
- Preserve SSH section-level server messages under a reserved `_error`
  key and render them in SSHTunnelForm; without this, SSH feature-gate
  failures reduced to an empty `ssh_tunnel: {}` and became invisible
  blockers
- Allow extra.ssh_tunnel errors through the blur-time error filter so
  the SSH form can surface them as the user is typing
2026-05-27 14:18:01 +02:00
Enzo Martellucci
af961ce252 fix(database): address second round of modal validation review feedback
- Treat any non-empty validation object as blocking in the save guard;
  the previous  check missed object-shaped returns like
  the duplicate database_name error and let the save proceed
- Forward ssh_tunnel into build_db_for_connection_test so tunnel-only
  databases are reached through the tunnel during validation instead of
  being pinged directly, mirroring the existing test_connection flow
- Honor explicit parameters.ssh === False as authoritative; a stale
  ssh_tunnel payload no longer keeps SSH validation on after the user
  toggles the tunnel off
- Emit both  and  as missing credentials so the
  active SSH login pane always surfaces the field error (the backend
  doesn't know which method the user picked)
2026-05-27 13:33:33 +02:00
Enzo Martellucci
baf701c022 fix(database): address review feedback on modal validation regressions
- Drop private_key implies private_key_password check; the schema permits
  unencrypted private keys and the prior check rejected valid SSH configs
- Surface SSH feature-flag and missing-port errors as field-level
  SupersetError entries instead of raising 400s, so blur-driven validation
  shows field hints rather than hard toasts
- Return null from both stale paths in useDatabaseValidation so callers
  can distinguish discarded responses from real outcomes and skip caching
- Add isValidating to LabeledErrorBoundInputProps so the prop typechecks
  explicitly instead of via the [x: string]: any index signature
2026-05-27 12:47:56 +02:00
Enzo Martellucci
ee3f8107f9 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-27 12:14:55 +02:00
Enzo Martellucci
9f7624cdc6 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-08 22:43:52 +02:00
Enzo Martellucci
f846e106b8 fix(database): tighten validate_parameters SSH and bypass-engine paths
Three correctness fixes to ValidateDatabaseParametersCommand:

1. Bypass engines (bigquery, datastore, snowflake) now also surface
   database_name uniqueness errors and SSH tunnel field errors during
   progressive validation, instead of silently passing.

2. The SSH feature-flag and database-port guards now fire when the UI
   marks parameters.ssh, not just when the ssh_tunnel payload is
   non-empty — the form sends an empty tunnel object in early stages.

3. The "parameters are missing" message for SSH tunnel fields now
   interpolates the %(missing)s placeholder via gettext, so the
   response surfaces the actual missing fields instead of the literal
   token.

Adds unit tests for each branch and removes the now-unused
_validate_database_name helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:32:35 +02:00
Enzo Martellucci
3e26f0218f fix(database): retry blur validation after a transient request failure
When the validate_parameters request failed without a structured error
body (e.g. network drop), getValidation returned an empty object that
the caller could not distinguish from "validation passed" — and the
blur dedup cache was already updated, so the same form state would never
revalidate until the user changed a field.

Have getValidation return null on unexpected failure and only update
the snapshot cache after a usable response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:32:10 +02:00
Enzo Martellucci
864b27d0e3 test(database): explicitly wait for each blur validation in cypress
The two ``show error alerts on dynamic form`` cases relied on a single
60s ``should('not.be.disabled')`` after typing five fields, but the
``{ timeout: 60000 }`` option on ``.should()`` is not honoured the way
it is on the query — the assertion still uses the project's 8s default
and times out before the chained validation calls complete.

Match the master cadence: explicitly blur each field and wait for the
``@validateParams`` interception before moving on, so the button-state
assertion only fires once validation is settled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:52:14 +02:00
Enzo Martellucci
86b3d8922c test(database): cover new validate command branches
Adds unit tests for the duplicate-database-name check (create + update
paths, plus the bypass-engine path), the SSH tunnel feature-flag and
db-port guards, and the SSH tunnel field-level error collection
(missing required fields, missing credentials, private key without
password). Brings patch coverage on commands/database/validate.py up
from ~44%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:53 +02:00
Enzo Martellucci
9651d0e673 test(database): stabilize DatabaseModal jest tests on slow CI
userEvent.type fires keystrokes serially through React's event system,
which races with the new debounced validation: ports rendered as number
inputs lose values mid-typing, and dynamic-form tests time out at the
20s default while tabbing between five fields. userEvent.paste also
no longer supplies a clipboardData object the Select onPaste handler can
read.

Switch the affected interactions to fireEvent.change/blur (single-shot,
no per-key validation churn) and revert the paste case to fireEvent.paste,
matching the master form. Behavior under test is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:33 +02:00
Enzo Martellucci
15ec3bbb87 fix(database): prevent stale validation errors from out-of-order responses
The blur-debounced validation can fire multiple in-flight requests as the
user types across fields. Without sequencing, an earlier request that
returns later overwrites the result of a newer one, leaving the modal
showing stale errors and keeping the Connect button disabled even after
the form is fully valid.

Track a per-call request id and only update validation state when the
response corresponds to the latest request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:16 +02:00
Enzo Martellucci
e4f8472e2e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-06 10:45:18 +02:00
Enzo Martellucci
3a52d609c9 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-05 23:44:17 +02:00
Enzo Martellucci
edc8e4b3ab Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-04-27 10:33:23 +02:00
Enzo Martellucci
f6ac345ef3 fix(cypress): wait for final validation to settle before asserting button state 2026-03-18 15:24:12 +01:00
Enzo Martellucci
d036ef4455 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 14:57:15 +01:00
Enzo Martellucci
6c69cc23ea lint 2026-03-18 14:56:58 +01:00
Enzo Martellucci
15b28631bf chore: remove duplicated handleClearValidationErrors function 2026-03-18 14:44:13 +01:00
Enzo Martellucci
e7c9cf0d04 refactor(database): simplify SSH tunnel error accumulation in useDatabaseValidation 2026-03-18 11:57:35 +01:00
Enzo Martellucci
2f980320b6 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 11:55:33 +01:00
Enzo Martellucci
d1ec3ebb40 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-03-12 23:17:01 +01:00
Enzo Martellucci
13ed9b5bad fix CI test 2026-02-16 16:32:56 +01:00
Enzo Martellucci
0bfaf3c50e perf(database): skip redundant validation API calls on blur 2026-02-16 11:10:34 +01:00
Enzo Martellucci
055fa360bb Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-14 21:46:20 +01:00
Enzo Martellucci
7d53e4d708 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-10 16:03:25 +01:00
Enzo Martellucci
c0be0485b3 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-02-04 09:41:19 +01:00
Enzo Martellucci
84c228e28b Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-28 11:27:56 +01:00
Enzo Martellucci
899ecf8214 fix: update RTL tests to match new behavior 2026-01-20 11:16:06 +01:00
Enzo Martellucci
4e156dc41e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-20 09:44:00 +01:00
Enzo Martellucci
2df224370e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-13 16:41:44 +01:00
Enzo Martellucci
a44b8a6cf0 feat(database): add SSH tunnel validation to database parameters endpoint
- Remove early return in frontend validation hook when SSH is enabled,
  allowing backend validation to run for all database parameters
- Add SSH tunnel field validation in ValidateDatabaseParametersCommand
  to validate server_address, server_port, username, and credentials
- Add DatabaseSSHTunnelValidation schema for partial SSH tunnel data
  validation without strict authentication requirements
- Add ssh_tunnel field to DatabaseValidateParametersSchema
- Parse SSH tunnel errors in frontend and display under ssh_tunnel key
- Collect database_name duplicate errors alongside parameter errors
2026-01-02 17:53:01 +01:00
Enzo Martellucci
ad92ec683b wip: add validation loading state to SSH tunnel form fields 2025-12-31 17:35:50 +01:00
Enzo Martellucci
6aef573304 feat(database): add validation loading state and duplicate name check
- Add isValidating prop to TableCatalog, ValidatedInputField, and
  CommonParameters to show loading spinner during validation
- Fix LabeledErrorBoundInput hasFeedback to display spinner while
  validating, not just on errors
- Add duplicate database name validation to validate_parameters
  endpoint for real-time feedback before form submission
2025-12-31 17:23:13 +01:00
20 changed files with 1422 additions and 820 deletions

View File

@@ -63,56 +63,57 @@ describe('Add database', () => {
it('show error alerts on dynamic form for bad host', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('badhost', { force: true });
cy.get('input[name="port"]').type('5432', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.get('body').click(0, 0);
cy.get('input[name="host"]').type('badhost', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5432', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]')
.type('testusername', { force: true })
.blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
});
it('show error alerts on dynamic form for bad port', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true });
cy.get('body').click(0, 0);
cy.get('input[name="host"]').type('localhost', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]')
.type('testusername', { force: true })
.blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains('.ant-form-item-explain-error', 'The port is closed').should(
'exist',
);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
hasFeedback={!!hasError}
hasFeedback={isValidating || !!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword

View File

@@ -31,5 +31,6 @@ export interface LabeledErrorBoundInputProps {
id?: string;
classname?: string;
visibilityToggle?: boolean;
isValidating?: boolean;
[x: string]: any;
}

View File

@@ -519,7 +519,8 @@ const Select = forwardRef(
handleSelectAll();
}}
>
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
{t('Select all')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
</Button>
<Button
type="link"
@@ -536,7 +537,8 @@ const Select = forwardRef(
handleDeselectAll();
}}
>
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
{t('Clear')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
</Button>
</StyledBulkActionsContainer>
),

View File

@@ -182,10 +182,7 @@ testWithAssets(
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
postsAfterClearAll.push(req.url());
}
};

View File

@@ -164,7 +164,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
processedData = filteredData.map(d => ({
...d,
radius: radiusScale(Math.sqrt(d.m2)),
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
fillColor:
d.m1 != null ? (colorFn(d.m1) ?? theme.colorBorder) : theme.colorBorder,
}));
}

View File

@@ -285,7 +285,9 @@ test('clears undo history after hydrating the dashboard', async () => {
expect(hydrateDashboard).toHaveBeenCalled();
expect(clearDashboardHistory).toHaveBeenCalled();
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
const hydrateOrder = (hydrateDashboard as jest.Mock).mock
.invocationCallOrder[0];
const clearOrder = (clearDashboardHistory as jest.Mock).mock
.invocationCallOrder[0];
expect(clearOrder).toBeGreaterThan(hydrateOrder);
});

View File

@@ -243,6 +243,7 @@ export const accessTokenField = ({
validationErrors,
db,
isEditMode,
isValidating,
default_value,
description,
}: FieldPropTypes) => (
@@ -250,6 +251,7 @@ export const accessTokenField = ({
id="access_token"
name="access_token"
required={required}
isValidating={isValidating}
visibilityToggle={!isEditMode}
value={db?.parameters?.access_token}
validationMethods={{ onBlur: getValidation }}

View File

@@ -33,6 +33,7 @@ export const TableCatalog = ({
getValidation,
validationErrors,
db,
isValidating,
isPublic = true,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
@@ -53,6 +54,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-input"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
@@ -86,6 +88,7 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-url"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}

View File

@@ -49,11 +49,13 @@ export const validatedInputField = ({
validationErrors,
db,
field,
isValidating,
}: FieldPropTypes) => (
<ValidatedInput
id={field}
name={field}
required={required}
isValidating={isValidating}
value={db?.parameters?.[field as keyof DatabaseParameters]}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]}

View File

@@ -18,18 +18,21 @@
*/
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import {
Form,
FormLabel,
Col,
Row,
LabeledErrorBoundInput,
Icons,
Tooltip,
} from '@superset-ui/core/components';
import { Input } from '@superset-ui/core/components/Input';
import { Radio } from '@superset-ui/core/components/Radio';
import { Icons } from '@superset-ui/core/components/Icons';
import { DatabaseObject, FieldPropTypes } from '../types';
import { DatabaseObject, CustomEventHandlerType } from '../types';
import { AuthType } from '.';
const StyledDiv = styled.div`
@@ -48,50 +51,73 @@ const StyledFormItem = styled(Form.Item)`
margin-bottom: 0 !important;
`;
const StyledInputPassword = styled(Input.Password)`
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
`;
interface SSHTunnelFormProps {
db: DatabaseObject | null;
onSSHTunnelParametersChange: CustomEventHandlerType;
setSSHTunnelLoginMethod: (method: AuthType) => void;
isValidating?: boolean;
validationErrors?: JsonObject | null;
getValidation: () => void;
}
const SSHTunnelForm = ({
db,
onSSHTunnelParametersChange,
setSSHTunnelLoginMethod,
}: {
db: DatabaseObject | null;
onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange'];
setSSHTunnelLoginMethod: (method: AuthType) => void;
}) => {
isValidating = false,
validationErrors,
getValidation,
}: SSHTunnelFormProps) => {
const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password);
const sshErrors = validationErrors?.ssh_tunnel || {};
const sshSectionError = sshErrors?._error;
return (
<Form>
{sshSectionError && (
<StyledRow gutter={16}>
<Col xs={24}>
<Alert
type="error"
showIcon
message={sshSectionError}
data-test="ssh-tunnel-section-error"
/>
</Col>
</StyledRow>
)}
<StyledRow gutter={16}>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_address" required>
{t('SSH Host')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_address"
name="server_address"
type="text"
label={t('SSH Host')}
required
placeholder={t('e.g. 127.0.0.1')}
value={db?.ssh_tunnel?.server_address || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_address}
isValidating={isValidating}
data-test="ssh-tunnel-server_address-input"
/>
</StyledDiv>
</Col>
<Col xs={24} md={12}>
<StyledDiv>
<FormLabel htmlFor="server_port" required>
{t('SSH Port')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="server_port"
name="server_port"
label={t('SSH Port')}
required
placeholder={t('22')}
type="number"
value={db?.ssh_tunnel?.server_port}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_port}
isValidating={isValidating}
data-test="ssh-tunnel-server_port-input"
/>
</StyledDiv>
@@ -100,15 +126,17 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="username" required>
{t('Username')}
</FormLabel>
<Input
<LabeledErrorBoundInput
id="username"
name="username"
type="text"
label={t('Username')}
required
placeholder={t('e.g. Analytics')}
value={db?.ssh_tunnel?.username || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.username}
isValidating={isValidating}
data-test="ssh-tunnel-username-input"
/>
</StyledDiv>
@@ -148,16 +176,20 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="password" required>
{t('SSH Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="password"
name="password"
label={t('SSH Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.password}
isValidating={isValidating}
data-test="ssh-tunnel-password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />
@@ -182,30 +214,47 @@ const SSHTunnelForm = ({
<FormLabel htmlFor="private_key" required>
{t('Private Key')}
</FormLabel>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
<StyledFormItem
validateStatus={
isValidating
? 'validating'
: sshErrors?.private_key
? 'error'
: 'success'
}
help={sshErrors?.private_key}
hasFeedback={isValidating || !!sshErrors?.private_key}
>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
onBlur={getValidation}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledFormItem>
</StyledDiv>
</Col>
</StyledRow>
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<FormLabel htmlFor="private_key_password" required>
{t('Private Key Password')}
</FormLabel>
<StyledInputPassword
<LabeledErrorBoundInput
id="private_key_password"
name="private_key_password"
label={t('Private Key Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.private_key_password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.private_key_password}
isValidating={isValidating}
data-test="ssh-tunnel-private_key_password-input"
iconRender={visible =>
iconRender={(visible: boolean) =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />

View File

@@ -1212,26 +1212,40 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
fireEvent.change(SSHTunnelServerAddressInput, {
target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
fireEvent.change(SSHTunnelServerPortInput, {
target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
fireEvent.change(SSHTunnelUsernameInput, {
target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
fireEvent.change(SSHTunnelPasswordInput, {
target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
});
test('properly interacts with SSH Tunnel form textboxes', async () => {
@@ -1250,26 +1264,40 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
fireEvent.change(SSHTunnelServerAddressInput, {
target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
fireEvent.change(SSHTunnelServerPortInput, {
target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
fireEvent.change(SSHTunnelUsernameInput, {
target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
fireEvent.change(SSHTunnelPasswordInput, {
target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
});
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
@@ -1364,7 +1392,10 @@ describe('DatabaseModal', () => {
}),
);
const textboxes = screen.getAllByRole('textbox');
// Wait for step 2 to render
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
const portField = screen.getByRole('spinbutton');
const databaseNameField = textboxes[1];
@@ -1380,15 +1411,20 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeDisabled();
userEvent.type(hostField, 'localhost');
userEvent.type(portField, '5432');
userEvent.type(databaseNameField, 'postgres');
userEvent.type(usernameField, 'testdb');
userEvent.type(passwordField, 'demoPassword');
fireEvent.change(hostField, { target: { value: 'localhost' } });
fireEvent.blur(hostField);
fireEvent.change(portField, { target: { value: '5432' } });
fireEvent.blur(portField);
fireEvent.change(databaseNameField, { target: { value: 'postgres' } });
fireEvent.blur(databaseNameField);
fireEvent.change(usernameField, { target: { value: 'testdb' } });
fireEvent.blur(usernameField);
fireEvent.change(passwordField, { target: { value: 'demoPassword' } });
fireEvent.blur(passwordField);
await waitFor(() => expect(connectButton).toBeEnabled());
expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument();
await waitFor(() => expect(portField).toHaveValue(5432));
expect(hostField).toHaveValue('localhost');
expect(portField).toHaveValue(5432);
expect(databaseNameField).toHaveValue('postgres');
@@ -1397,10 +1433,48 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeEnabled();
userEvent.click(connectButton);
// Verify that validation was called during the form interaction
// Note: With the optimized validation, redundant calls on the same db state are skipped
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(5);
).toBeGreaterThan(0);
});
});
test('does not fire redundant validation on blur when db has not changed', async () => {
setup();
userEvent.click(
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
// Type a value and blur - should trigger validation
fireEvent.change(hostField, { target: { value: 'localhost' } });
fireEvent.blur(hostField);
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
// Blur again without changing the value - should NOT trigger another validation
fireEvent.focus(hostField);
fireEvent.blur(hostField);
// Wait a tick to ensure no additional calls are made
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
});
});

View File

@@ -668,6 +668,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
hasValidated,
setHasValidated,
] = useDatabaseValidation();
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
const [showCTAbtns, setShowCTAbtns] = useState(false);
const [dbName, setDbName] = useState('');
@@ -775,6 +776,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const handleClearValidationErrors = useCallback(() => {
setValidationErrors(null);
setHasValidated(false);
lastValidatedDbSnapshotRef.current = null;
clearError();
}, [setValidationErrors, setHasValidated, clearError]);
@@ -851,6 +853,16 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange],
);
const handleTextChange = useCallback(
({ target }: { target: HTMLInputElement }) => {
onChange(ActionType.TextChange, {
name: target.name,
value: target.value,
});
},
[onChange],
);
const handleChangeWithValidation = useCallback(
(
actionType: ActionType,
@@ -862,6 +874,21 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange, handleClearValidationErrors],
);
const getBlurValidation = useCallback(async () => {
const currentDbSnapshot = JSON.stringify(db);
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
return [];
}
const result = await getValidation(db);
// Only cache after a request that produced a usable response. ``null``
// signals an unexpected/network failure, in which case we leave the
// snapshot untouched so the next blur retries.
if (result !== null) {
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
}
return result;
}, [db, getValidation]);
const onClose = () => {
setDB({ type: ActionType.Reset });
setHasConnectedDb(false);
@@ -940,7 +967,15 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}
const errors = await getValidation(dbToUpdate, true);
if (!isEmpty(validationErrors) || errors?.length) {
// ``getValidation`` returns ``[]`` on success, a field-keyed object
// for blocking errors (e.g. the duplicate ``database_name`` check),
// and ``null`` for stale or unexpected responses. During save we
// cannot proceed without a usable result, so treat ``null`` as
// blocking too — only ``[]`` is a clean pass.
const hasReturnedErrors =
errors === null ||
(Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors));
if (!isEmpty(validationErrors) || hasReturnedErrors) {
addDangerToast(
t('Connection failed, please check your connection settings.'),
);
@@ -1847,7 +1882,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
name: target.name,
value: target.value,
});
handleClearValidationErrors();
}}
setSSHTunnelLoginMethod={(method: AuthType) =>
setDB({
@@ -1855,6 +1889,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
payload: { login_method: method },
})
}
isValidating={isValidating}
validationErrors={validationErrors}
getValidation={getBlurValidation}
/>
);
@@ -1930,13 +1967,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
});
}}
onParametersChange={handleParametersChange}
onChange={({ target }: { target: HTMLInputElement }) =>
handleChangeWithValidation(ActionType.TextChange, {
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
onChange={handleTextChange}
getValidation={getBlurValidation}
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
clearValidationErrors={handleClearValidationErrors}

View File

@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
// Only return permission id=10, not id=20
return Promise.resolve({
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
return Promise.reject(new Error('network error'));
}
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleA.permission_ids.map(pid => ({
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
},
});
}
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({

View File

@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
import type { QueryEditor } from '../SqlLab/types';
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
type LogEventSource =
| 'dashboard'
| 'embedded_dashboard'
| 'explore'
| 'sqlLab'
| 'slice';
interface LogEventData {
source?: LogEventSource;

View File

@@ -819,16 +819,13 @@ export function useDatabaseValidation() {
);
const [isValidating, setIsValidating] = useState(false);
const [hasValidated, setHasValidated] = useState(false);
const latestRequestIdRef = useRef(0);
const getValidation = useCallback(
async (database: Partial<DatabaseObject> | null, onCreate = false) => {
if (database?.parameters?.ssh) {
setValidationErrors(null);
setIsValidating(false);
setHasValidated(true);
return Promise.resolve([]);
}
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
setIsValidating(true);
try {
@@ -837,6 +834,9 @@ export function useDatabaseValidation() {
body: JSON.stringify(transformDB(database)),
headers: { 'Content-Type': 'application/json' },
});
// Stale responses return ``null`` so callers can tell the result
// apart from a real, current outcome and skip caching it.
if (!isLatest()) return null;
setValidationErrors(null);
setIsValidating(false);
setHasValidated(true);
@@ -845,12 +845,18 @@ export function useDatabaseValidation() {
if (typeof error.json === 'function') {
return error.json().then(({ errors = [] }) => {
const parsedErrors = errors
.filter((err: { error_type: string }) => {
.filter((err: { error_type: string; extra?: JsonObject }) => {
const allowed = [
'CONNECTION_MISSING_PARAMETERS_ERROR',
'CONNECTION_ACCESS_DENIED_ERROR',
'INVALID_PAYLOAD_SCHEMA_ERROR',
];
// SSH-tunnel section errors carry their own ``ssh_tunnel``
// marker and need to surface during blur validation too,
// otherwise feature-gate failures become invisible
// blockers (the save guard would still trip but with no
// hint about why).
if (err.extra?.ssh_tunnel) return true;
return allowed.includes(err.error_type) || onCreate;
})
.reduce((acc: JsonObject, err2: any) => {
@@ -866,6 +872,28 @@ export function useDatabaseValidation() {
return acc;
}
if (extra?.ssh_tunnel) {
// Field-level errors come in via ``extra.missing``;
// section-level errors (e.g. feature flag disabled) do
// not name a specific field, so preserve the server
// message under a reserved ``_error`` key so the SSH
// form can render it instead of silently dropping it.
const missingFields = extra.missing ?? [];
acc.ssh_tunnel = {
...acc.ssh_tunnel,
...Object.fromEntries(
missingFields.map((field: string) => [
field,
'This is a required field',
]),
),
...(missingFields.length === 0 && message
? { _error: message }
: {}),
};
return acc;
}
if (extra?.invalid) {
extra.invalid.forEach((field: string) => {
acc[field] = message;
@@ -885,6 +913,7 @@ export function useDatabaseValidation() {
return acc;
}, {});
if (!isLatest()) return null;
setValidationErrors(parsedErrors);
setIsValidating(false);
setHasValidated(true);
@@ -893,9 +922,11 @@ export function useDatabaseValidation() {
}
console.error('Unexpected error during validation:', error);
setIsValidating(false);
setHasValidated(true);
return {};
if (isLatest()) {
setIsValidating(false);
setHasValidated(true);
}
return null;
}
},
[setValidationErrors],

View File

@@ -19,6 +19,7 @@ from typing import Any, Optional
from flask_babel import gettext as __
from superset import is_feature_enabled
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import (
DatabaseOfflineError,
@@ -33,6 +34,7 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.extensions import event_logger
from superset.models.core import Database
from superset.utils import json
from superset.utils.ssh_tunnel import unmask_password_info
BYPASS_VALIDATION_ENGINES = {"bigquery", "datastore", "snowflake"}
@@ -42,14 +44,23 @@ class ValidateDatabaseParametersCommand(BaseCommand):
self._properties = properties.copy()
self._model: Optional[Database] = None
def run(self) -> None:
def run(self) -> None: # noqa: C901
self.validate()
engine = self._properties["engine"]
driver = self._properties.get("driver")
if engine in BYPASS_VALIDATION_ENGINES:
# Skip engines that are only validated onCreate
# Skip engines that are only validated onCreate, but still surface
# database_name uniqueness and SSH tunnel field errors so the
# progressive validation flow stays consistent across engines.
errors: list[SupersetError] = []
if database_name_error := self._get_database_name_error():
errors.append(database_name_error)
errors.extend(self._get_ssh_tunnel_errors())
if errors:
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
return
engine_spec = get_engine_spec(engine, driver)
@@ -65,8 +76,17 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
# perform initial validation
# perform initial validation (host, port, database, username)
errors = engine_spec.validate_parameters(self._properties) # type: ignore
# Collect database_name errors along with parameter errors
if database_name_error := self._get_database_name_error():
errors.append(database_name_error)
# Collect SSH tunnel errors
ssh_tunnel_errors = self._get_ssh_tunnel_errors()
errors.extend(ssh_tunnel_errors)
if errors:
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
@@ -92,11 +112,23 @@ class ValidateDatabaseParametersCommand(BaseCommand):
)
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
# Forward the SSH tunnel into the connection test so that
# tunnel-only databases are reached through the tunnel rather
# than directly, mirroring the existing test_connection flow.
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
self._model.ssh_tunnel,
)
database = DatabaseDAO.build_db_for_connection_test(
server_cert=self._properties.get("server_cert", ""),
extra=self._properties.get("extra", "{}"),
impersonate_user=self._properties.get("impersonate_user", False),
encrypted_extra=json.dumps(encrypted_extra),
ssh_tunnel=ssh_tunnel_properties,
)
database.set_sqlalchemy_uri(sqlalchemy_uri)
database.db_engine_spec.mutate_db_for_connection_test(database)
@@ -138,6 +170,116 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
def validate(self) -> None:
def _load_model(self) -> None:
"""Load the existing database model if updating."""
if (database_id := self._properties.get("id")) is not None:
self._model = DatabaseDAO.find_by_id(database_id)
def _get_database_name_error(self) -> Optional[SupersetError]:
"""Check for duplicate database name and return error if found."""
database_id = self._properties.get("id")
if database_name := self._properties.get("database_name"):
is_unique = (
DatabaseDAO.validate_update_uniqueness(database_id, database_name)
if database_id is not None
else DatabaseDAO.validate_uniqueness(database_name)
)
if not is_unique:
return SupersetError(
message=__("A database with the same name already exists."),
error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["database_name"]},
)
return None
def validate(self) -> None:
"""Load the model in preparation for run()."""
self._load_model()
def _get_ssh_tunnel_errors(self) -> list[SupersetError]:
"""Validate SSH tunnel fields and return list of errors."""
errors: list[SupersetError] = []
ssh_tunnel = self._properties.get("ssh_tunnel") or {}
parameters = self._properties.get("parameters") or {}
# ``parameters.ssh`` is the UI toggle. When it's explicitly false the
# user has turned SSH off and any stale ``ssh_tunnel`` payload should
# be ignored — otherwise toggling off after partially filling the
# tunnel form would still trip validation. When the toggle is absent
# (older callers / save-time payloads) fall back to inferring from
# the presence of an ``ssh_tunnel`` object.
if "ssh" in parameters:
if not parameters.get("ssh"):
return errors
ssh_enabled = True
else:
ssh_enabled = bool(ssh_tunnel)
if not ssh_enabled:
return errors
if not is_feature_enabled("SSH_TUNNELING"):
errors.append(
SupersetError(
message=__("SSH Tunneling is not enabled"),
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
extra={"ssh_tunnel": True},
)
)
return errors
if not parameters.get("port"):
# ``port`` is a database parameter (not a tunnel field), so it
# surfaces via the top-level ``missing`` path and lands on the
# database port input rather than the SSH tunnel section.
errors.append(
SupersetError(
message=__(
"A database port is required when connecting via SSH Tunnel."
),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": ["port"]},
)
)
# Required fields on the tunnel itself
required_fields = ["server_address", "server_port", "username"]
missing = [f for f in required_fields if not ssh_tunnel.get(f)]
if missing:
errors.append(
SupersetError(
message=__(
"One or more parameters are missing: %(missing)s",
missing=", ".join(missing),
),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": missing, "ssh_tunnel": True},
)
)
# Either password or private_key is required. The active login
# method is a client-side toggle, so flag both fields as missing —
# the modal renders only one pane at a time and will surface the
# error on whichever field the user can see.
has_password = bool(ssh_tunnel.get("password"))
has_private_key = bool(ssh_tunnel.get("private_key"))
if not has_password and not has_private_key:
errors.append(
SupersetError(
message=__("Must provide credentials for the SSH Tunnel"),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={
"missing": ["password", "private_key"],
"ssh_tunnel": True,
},
)
)
return errors

View File

@@ -443,6 +443,24 @@ class DatabaseValidateParametersSchema(Schema):
required=True,
metadata={"description": configuration_method_description},
)
ssh_tunnel = fields.Nested("DatabaseSSHTunnelValidation", allow_none=True)
class DatabaseSSHTunnelValidation(Schema):
"""SSH Tunnel schema for validation.
Allows partial data without strict authentication requirements.
"""
id = fields.Integer(
allow_none=True, metadata={"description": "SSH Tunnel ID (for updates)"}
)
server_address = fields.String(allow_none=True)
server_port = fields.Integer(allow_none=True)
username = fields.String(allow_none=True)
password = fields.String(required=False, allow_none=True)
private_key = fields.String(required=False, allow_none=True)
private_key_password = fields.String(required=False, allow_none=True)
class DatabaseSSHTunnel(Schema):

View File

@@ -205,3 +205,487 @@ def test_command_with_oauth2_not_configured(mocker: MockerFixture) -> None:
extra={"engine_name": "gsheets"},
)
]
def test_command_duplicate_database_name(mocker: MockerFixture) -> None:
"""
Validation surfaces a duplicate-name error for a new database with a
name already in use.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = False
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"database_name": "duplicate",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.error_type == SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR
and err.extra is not None
and err.extra.get("invalid") == ["database_name"]
for err in excinfo.value.errors
)
def test_command_duplicate_database_name_on_update(mocker: MockerFixture) -> None:
"""
Validation uses ``validate_update_uniqueness`` when an ``id`` is provided.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.find_by_id.return_value = mocker.MagicMock()
DatabaseDAO.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"id": 1,
"engine": "postgresql",
"database_name": "existing",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError):
command.run()
DatabaseDAO.validate_update_uniqueness.assert_called_once_with(1, "existing")
def test_command_duplicate_database_name_bypass_engine(
mocker: MockerFixture,
) -> None:
"""
Bypass engines (e.g. ``bigquery``) still validate database name uniqueness.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = False
properties = {
"engine": "bigquery",
"database_name": "duplicate",
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert excinfo.value.errors[0].error_type == (
SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR
)
def test_validate_ssh_tunnel_feature_disabled(mocker: MockerFixture) -> None:
"""
Enabling SSH tunnel without the feature flag surfaces a field-level
SupersetError tagged with ``ssh_tunnel`` so the modal can map it back
to the SSH tunnel section instead of throwing a hard 400 toast.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=False,
)
properties = {
"engine": "postgresql",
"ssh_tunnel": {"server_address": "localhost"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_validate_ssh_tunnel_missing_db_port(mocker: MockerFixture) -> None:
"""
SSH tunneling requires a database port; the error is surfaced via the
top-level ``missing`` extra so it lands on the database port input.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"ssh_tunnel": {"server_address": "localhost"},
"parameters": {"host": "localhost"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None
and "port" in (err.extra.get("missing") or [])
and not err.extra.get("ssh_tunnel")
for err in excinfo.value.errors
)
def test_get_ssh_tunnel_errors_missing_required_fields(
mocker: MockerFixture,
) -> None:
"""
SSH tunnel collects missing required fields (server_address, server_port,
username) and missing credentials.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None
and err.extra.get("ssh_tunnel") is True
and err.extra.get("missing") == ["server_port", "username"]
for err in excinfo.value.errors
)
assert any(
err.extra is not None
and err.extra.get("ssh_tunnel") is True
and err.extra.get("missing") == ["password", "private_key"]
for err in excinfo.value.errors
)
def test_get_ssh_tunnel_errors_unencrypted_private_key_is_valid(
mocker: MockerFixture,
) -> None:
"""
An unencrypted private key (no ``private_key_password``) is a valid
SSH tunnel credential — validation should not flag it as missing.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {
"server_address": "ssh.example.com",
"server_port": 22,
"username": "ssh-user",
"private_key": "----- KEY -----",
},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_ssh_tunnel_forwarded_to_connection_test(
mocker: MockerFixture,
) -> None:
"""
The SSH tunnel payload is forwarded into the connection test so
tunnel-only databases are reached through the tunnel rather than
pinged directly.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
ssh_tunnel = {
"server_address": "ssh.example.com",
"server_port": 22,
"username": "ssh-user",
"password": "secret",
}
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": ssh_tunnel,
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
DatabaseDAO.build_db_for_connection_test.assert_called_once()
assert (
DatabaseDAO.build_db_for_connection_test.call_args.kwargs["ssh_tunnel"]
== ssh_tunnel
)
def test_get_ssh_tunnel_errors_skipped_when_parameters_ssh_false(
mocker: MockerFixture,
) -> None:
"""
An explicit ``parameters.ssh == False`` is authoritative and skips SSH
tunnel validation even when a stale ``ssh_tunnel`` object is still in
the payload — otherwise toggling SSH off after partial entry would
leave hidden validation errors blocking save.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"database_name": "ok",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
"ssh": False,
},
# Stale partial tunnel payload from before the user toggled off:
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_get_ssh_tunnel_errors_skipped_when_not_enabled(
mocker: MockerFixture,
) -> None:
"""
SSH tunnel validation is a no-op when ssh is not enabled and no tunnel
is provided.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"database_name": "ok",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_bypass_engine_surfaces_ssh_tunnel_errors(mocker: MockerFixture) -> None:
"""
Bypass engines also surface SSH tunnel field errors so the progressive
validation flow stays consistent across engine types.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
properties = {
"engine": "snowflake",
"database_name": "ok",
"parameters": {"port": 443},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_validate_ssh_tunnel_feature_disabled_via_parameters_ssh(
mocker: MockerFixture,
) -> None:
"""
The SSH feature-flag guard fires when the UI marks ``parameters.ssh``
even if ``ssh_tunnel`` itself is empty during progressive validation.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=False,
)
properties = {
"engine": "postgresql",
"parameters": {"host": "localhost", "port": 5432, "ssh": True},
"ssh_tunnel": {},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_ssh_tunnel_missing_message_is_interpolated(
mocker: MockerFixture,
) -> None:
"""
The translated ``parameters are missing`` message is interpolated with
the actual missing fields rather than the raw ``%(missing)s`` token.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
missing_field_messages = [
err.message
for err in excinfo.value.errors
if err.extra is not None
and err.extra.get("missing")
and err.extra.get("ssh_tunnel") # noqa: E501
]
assert missing_field_messages
assert all("%(missing)s" not in msg for msg in missing_field_messages)
assert any("server_port" in msg for msg in missing_field_messages)