Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ exasol = ["sqlalchemy-exasol >= 2.4.0, < 8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
"joserfc>=1.0.0,<2.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
Expand Down
3 changes: 3 additions & 0 deletions requirements/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ cryptography==46.0.7
# -c requirements/base-constraint.txt
# apache-superset
# authlib
# joserfc
# paramiko
# pyjwt
# pyopenssl
Expand Down Expand Up @@ -471,6 +472,8 @@ jmespath==1.1.0
# via
# boto3
# botocore
joserfc==1.6.8
# via apache-superset
jsonpath-ng==1.7.0
# via
# -c requirements/base-constraint.txt
Expand Down
11 changes: 10 additions & 1 deletion superset-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion superset-frontend/packages/superset-ui-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.44.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* 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 {
ConfigurationMethod,
DatabaseObject,
Engines,
} from 'src/features/databases/types';
import { computeInitialIsPublic } from './index';

const baseDb: Partial<DatabaseObject> = {
configuration_method: ConfigurationMethod.DynamicForm,
database_name: 'test',
driver: 'apsw',
id: 1,
name: 'test',
is_managed_externally: false,
engine: Engines.GSheet,
};

test('computeInitialIsPublic: returns true when database is null/undefined', () => {
expect(computeInitialIsPublic(null)).toBe(true);
expect(computeInitialIsPublic(undefined)).toBe(true);
});

test('computeInitialIsPublic: returns true for non-gsheets engines', () => {
expect(
computeInitialIsPublic({ ...baseDb, engine: 'postgres' as string }),
).toBe(true);
});

test('computeInitialIsPublic: returns true for fresh gsheets connections', () => {
expect(computeInitialIsPublic({ ...baseDb })).toBe(true);
expect(
computeInitialIsPublic({ ...baseDb, masked_encrypted_extra: '{}' }),
).toBe(true);
});

test('computeInitialIsPublic: returns false when masked_encrypted_extra has content', () => {
expect(
computeInitialIsPublic({
...baseDb,
masked_encrypted_extra: JSON.stringify({
service_account_info: { type: 'service_account' },
}),
}),
).toBe(false);
});

test('computeInitialIsPublic: returns false when parameters.service_account_info is set', () => {
expect(
computeInitialIsPublic({
...baseDb,
parameters: { service_account_info: '{"key":"value"}' },
}),
).toBe(false);
});

test('computeInitialIsPublic: returns false when parameters.oauth2_client_info is set (OAuth2-only edit)', () => {
expect(
computeInitialIsPublic({
...baseDb,
parameters: {
// oauth2_client_info isn't in DatabaseParameters typing yet; this
// mirrors how an OAuth2-only edit-mode payload can arrive.
oauth2_client_info: { id: 'client-id' },
} as DatabaseObject['parameters'],
}),
).toBe(false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('EncryptedField', () => {

const createMockChangeMethods = () => ({
onEncryptedExtraInputChange: jest.fn(),
onClearEncryptedExtraKey: jest.fn(),
onParametersChange: jest.fn(),
onChange: jest.fn(),
onQueryChange: jest.fn(),
Expand Down Expand Up @@ -92,7 +93,12 @@ describe('EncryptedField', () => {
isValidating: false,
isEditMode: false,
editNewDb: false,
db: createMockDb('gsheets'),
// Default to bigquery so existing credential-UI assertions aren't
// affected by the gsheets-specific public/private dropdown. New tests
// below override the engine to 'gsheets' to cover the dropdown gating.
db: createMockDb('bigquery'),
isPublic: false,
setIsPublic: jest.fn(),
};

// Use actual encryptedCredentialsMap for data-driven tests
Expand Down Expand Up @@ -124,22 +130,32 @@ describe('EncryptedField', () => {

expect(() => render(<EncryptedField {...props} />)).not.toThrow();

expectParametersChange(props.changeMethods, undefined, '');
expect(props.changeMethods.onParametersChange).toHaveBeenCalledTimes(1);
// No engine-specific field name → mount effect skips the clear so it
// doesn't write `parameters[undefined] = ''` to state.
expect(props.changeMethods.onParametersChange).not.toHaveBeenCalled();
});

test.each([
['null engine', null, null],
['undefined engine', undefined, undefined],
['empty string engine', '', ''],
])('handles %s gracefully', (_description, engine, expectedName) => {
['null engine', null],
['undefined engine', undefined],
['empty string engine', ''],
])('handles %s gracefully', (_description, engine) => {
const mockDb = createMockDb(engine);
const props = { ...defaultProps, db: mockDb };

expect(() => render(<EncryptedField {...props} />)).not.toThrow();

expectParametersChange(props.changeMethods, expectedName, '');
expect(props.changeMethods.onParametersChange).toHaveBeenCalledTimes(1);
expect(props.changeMethods.onParametersChange).not.toHaveBeenCalled();
});

test('does not call onParametersChange when db is undefined (async load)', () => {
const props = { ...defaultProps, db: undefined };

expect(() => render(<EncryptedField {...props} />)).not.toThrow();

// Async edit-mode load: db hasn't arrived yet. The mount effect must
// NOT race with the incoming credentials by clearing them.
expect(props.changeMethods.onParametersChange).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -300,7 +316,7 @@ describe('EncryptedField', () => {

expectParametersChange(
props.changeMethods,
'service_account_info', // gsheets default
'credentials_info', // bigquery default
'',
);
});
Expand Down Expand Up @@ -328,8 +344,9 @@ describe('EncryptedField', () => {

expect(() => render(<EncryptedField {...props} />)).not.toThrow();

// Should still render the upload UI with undefined field name
expectParametersChange(props.changeMethods, undefined, '');
// Mount effect skips the parameters clear when there's no
// engine-specific field name to write to.
expect(props.changeMethods.onParametersChange).not.toHaveBeenCalled();
});

test('renders gracefully with malformed database parameters', () => {
Expand Down Expand Up @@ -357,7 +374,7 @@ describe('EncryptedField', () => {
expect(screen.getByText('Service Account')).toBeInTheDocument();

const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('name', 'service_account_info');
expect(textarea).toHaveAttribute('name', 'credentials_info');
expect(textarea).toHaveAttribute(
'placeholder',
'Paste content of service credentials JSON file here',
Expand All @@ -379,4 +396,109 @@ describe('EncryptedField', () => {
expect(select).toBeInTheDocument();
});
});

// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Google Sheets public/private dropdown', () => {
const gsheetsProps = {
...defaultProps,
db: createMockDb('gsheets'),
};

test('renders the dropdown for gsheets', () => {
render(<EncryptedField {...gsheetsProps} isPublic />);

expect(
screen.getByText('Type of Google Sheets allowed'),
).toBeInTheDocument();
expect(
screen.getByText('Publicly shared sheets only'),
).toBeInTheDocument();
});

test('does not render the dropdown for non-gsheets engines', () => {
render(<EncryptedField {...defaultProps} />);

expect(
screen.queryByText('Type of Google Sheets allowed'),
).not.toBeInTheDocument();
});

test('hides credential inputs when isPublic is true', () => {
render(<EncryptedField {...gsheetsProps} isPublic />);

expect(
screen.queryByText(
'How do you want to enter service account credentials?',
),
).not.toBeInTheDocument();
expect(screen.queryByText('Upload credentials')).not.toBeInTheDocument();
expect(screen.queryByText('Service Account')).not.toBeInTheDocument();
});

test('shows credential inputs when isPublic is false', () => {
render(<EncryptedField {...gsheetsProps} isPublic={false} />);

expect(
screen.getByText(
'How do you want to enter service account credentials?',
),
).toBeInTheDocument();
expect(screen.getByText('Upload credentials')).toBeInTheDocument();
});

test('hides credential textarea in edit mode when isPublic is true', () => {
render(<EncryptedField {...gsheetsProps} isPublic isEditMode />);

expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.queryByText('Service Account')).not.toBeInTheDocument();
});

test('toggling back to public clears stored credentials', () => {
const setIsPublic = jest.fn();
const changeMethods = createMockChangeMethods();
render(
<EncryptedField
{...gsheetsProps}
changeMethods={changeMethods}
isPublic={false}
setIsPublic={setIsPublic}
/>,
);

const dropdown = screen.getByText('Public and privately shared sheets');
fireEvent.mouseDown(dropdown);
fireEvent.click(screen.getByText('Publicly shared sheets only'));

expect(setIsPublic).toHaveBeenCalledWith(true);

// Clears in-flight `parameters.*` so the save-time merge does nothing.
expect(changeMethods.onParametersChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({
name: 'service_account_info',
value: '',
}),
}),
);
expect(changeMethods.onParametersChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({
name: 'oauth2_client_info',
value: '',
}),
}),
);

// Also deletes `masked_encrypted_extra` keys directly via the dedicated
// `ClearEncryptedExtraKey` action so previously stored credentials
// don't survive a toggle in edit mode.
expect(changeMethods.onClearEncryptedExtraKey).toHaveBeenCalledWith(
'service_account_info',
);
expect(changeMethods.onClearEncryptedExtraKey).toHaveBeenCalledWith(
'oauth2_client_info',
);
expect(changeMethods.onEncryptedExtraInputChange).not.toHaveBeenCalled();
});
});
});
Loading
Loading