Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import {
loadWBHistory,
processWBCommand,
sendWBCommandAction,
sendWbQueryAction,
workbenchResultsSelector,
} from 'uiSrc/slices/workbench/wb-results'
import { useDatabaseEnvironment } from 'uiSrc/components/hooks/useDatabaseEnvironment'
import { Environment } from 'apiClient'
import { DBInstanceFactory } from 'uiSrc/mocks/factories/database/DBInstance.factory'
import { ConnectionType } from 'uiSrc/slices/interfaces'

import WBViewWrapper from './WBViewWrapper'

Expand All @@ -36,15 +41,20 @@ jest.mock('uiSrc/pages/workbench/components/query', () => ({
default: jest.fn(),
}))

const QueryWrapperMock = (props: QueryProps) => (
<div
onKeyDown={(e: any) => props.onKeyDown?.(e, 'get')}
data-testid="query"
aria-label="query"
role="textbox"
tabIndex={0}
/>
)
let capturedOnSubmit: QueryProps['onSubmit'] | null = null

const QueryWrapperMock = (props: QueryProps) => {
capturedOnSubmit = props.onSubmit
return (
<div
onKeyDown={(e: any) => props.onKeyDown?.(e, 'get')}
data-testid="query"
aria-label="query"
role="textbox"
tabIndex={0}
/>
)
}

jest.mock('uiSrc/services', () => ({
...jest.requireActual('uiSrc/services'),
Expand All @@ -71,6 +81,10 @@ jest.mock('uiSrc/slices/app/plugins', () => ({

jest.mock('uiSrc/slices/workbench/wb-results', () => ({
...jest.requireActual('uiSrc/slices/workbench/wb-results'),
sendWbQueryAction: jest.fn(() => ({ type: '__test_sendWbQueryAction__' })),
sendWBCommandAction: jest.fn(() => ({
type: '__test_sendWBCommandAction__',
})),
sendWBCommandClusterAction: jest.fn(),
processUnsupportedCommand: jest.fn(),
updateCliCommandHistory: jest.fn,
Expand All @@ -80,6 +94,13 @@ jest.mock('uiSrc/slices/workbench/wb-results', () => ({
}),
}))

jest.mock('uiSrc/components/hooks/useDatabaseEnvironment', () => ({
useDatabaseEnvironment: jest.fn().mockReturnValue({
environment: 'unspecified',
isDangerousCommand: () => false,
}),
}))

jest.mock('uiSrc/slices/workbench/wb-tutorials', () => {
const defaultState = jest.requireActual(
'uiSrc/slices/workbench/wb-tutorials',
Expand Down Expand Up @@ -153,6 +174,157 @@ describe('WBViewWrapper', () => {
expect(screen.queryByTestId('clear-history-btn')).not.toBeInTheDocument()
})

describe('dangerous-command gating', () => {
const instance = DBInstanceFactory.build({
name: 'prod-db',
connectionType: ConnectionType.Standalone,
})

beforeEach(() => {
capturedOnSubmit = null
;(sendWbQueryAction as jest.Mock).mockClear()
;(connectedInstanceSelector as jest.Mock).mockImplementation(
Comment thread
valkirilov marked this conversation as resolved.
Outdated
() => instance,
)
})

afterEach(() => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: 'unspecified',
isDangerousCommand: () => false,
})
})

it('does not show the modal and dispatches when no command is dangerous', () => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: () => false,
})

render(<WBViewWrapper />)
act(() => {
capturedOnSubmit?.('PING')
})

expect(
screen.queryByTestId('type-to-confirm-modal-title'),
).not.toBeInTheDocument()
expect(sendWbQueryAction).toHaveBeenCalledTimes(1)
expect((sendWbQueryAction as jest.Mock).mock.calls[0][0]).toBe('PING')
})

it('shows the modal and defers dispatch when a command is dangerous', () => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: (cmd: string) =>
['FLUSHALL', 'FLUSHDB'].includes(cmd.toUpperCase()),
})

render(<WBViewWrapper />)
act(() => {
capturedOnSubmit?.('PING\nFLUSHALL\nFLUSHDB')
})

expect(
screen.getByTestId('type-to-confirm-modal-title'),
).toBeInTheDocument()
const description = screen.getByTestId(
'type-to-confirm-modal-description',
)
expect(description).toHaveTextContent(instance.name!)
expect(description).toHaveTextContent('FLUSHALL, FLUSHDB')
expect(sendWbQueryAction).not.toHaveBeenCalled()
})

it('gates a multi-line dangerous command (Monaco continuation)', () => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: (cmd: string) => cmd.toUpperCase() === 'FLUSHALL',
})

render(<WBViewWrapper />)
// Monaco joins continuation lines (the leading whitespace on the next
// line is preserved), so the verb arrives with an embedded newline.
act(() => {
capturedOnSubmit?.('FLUSHALL\n ASYNC')
})

expect(
screen.getByTestId('type-to-confirm-modal-title'),
).toBeInTheDocument()
expect(sendWbQueryAction).not.toHaveBeenCalled()
})

it('falls back to host:port when the connected instance has no name', () => {
const namelessInstance = DBInstanceFactory.build({
name: undefined,
host: 'h',
port: 6379,
connectionType: ConnectionType.Standalone,
})
;(connectedInstanceSelector as jest.Mock).mockImplementation(
() => namelessInstance,
)
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: (cmd: string) => cmd.toUpperCase() === 'FLUSHALL',
})

render(<WBViewWrapper />)
act(() => {
capturedOnSubmit?.('FLUSHALL')
})

expect(
screen.getByTestId('type-to-confirm-modal-description'),
).toHaveTextContent('h:6379')
})

it('dispatches the original batch after typing the database name and confirming', () => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: (cmd: string) => cmd.toUpperCase() === 'FLUSHALL',
})

render(<WBViewWrapper />)
act(() => {
capturedOnSubmit?.('PING\nFLUSHALL')
})

fireEvent.change(screen.getByTestId('type-to-confirm-modal-input'), {
target: { value: instance.name },
})
fireEvent.click(screen.getByTestId('type-to-confirm-modal-confirm-btn'))

expect(
screen.queryByTestId('type-to-confirm-modal-title'),
).not.toBeInTheDocument()
expect(sendWbQueryAction).toHaveBeenCalledTimes(1)
expect((sendWbQueryAction as jest.Mock).mock.calls[0][0]).toBe(
'PING\nFLUSHALL',
)
})

it('does not dispatch when the modal is cancelled', () => {
;(useDatabaseEnvironment as jest.Mock).mockReturnValue({
environment: Environment.Production,
isDangerousCommand: (cmd: string) => cmd.toUpperCase() === 'FLUSHALL',
})

render(<WBViewWrapper />)
act(() => {
capturedOnSubmit?.('FLUSHALL')
})

fireEvent.click(screen.getByTestId('type-to-confirm-modal-cancel-btn'))

expect(
screen.queryByTestId('type-to-confirm-modal-title'),
).not.toBeInTheDocument()
expect(sendWbQueryAction).not.toHaveBeenCalled()
})
})

it.skip('"onSubmit" for Cluster connection should call "sendWBCommandClusterAction"', async () => {
;(connectedInstanceSelector as jest.Mock).mockImplementation(() => ({
id: '123',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,18 @@ import {
changeSidePanel,
} from 'uiSrc/slices/panels/sidePanels'
import { InsightsPanelTabs, SidePanels } from 'uiSrc/slices/interfaces/insights'
import { useDatabaseEnvironment } from 'uiSrc/components/hooks/useDatabaseEnvironment'
import TypeToConfirmModal from 'uiSrc/components/type-to-confirm-modal'
import { getCommandsForExecution } from 'uiSrc/utils/monaco/monacoUtils'
import WBView from './WBView'

interface PendingSubmit {
value: string
commandId?: Nullable<string>
executeParams: CodeButtonParams
dangerousCommands: string[]
}

interface IState {
loading: boolean
instance: Instance
Expand Down Expand Up @@ -91,8 +101,11 @@ const WBViewWrapper = () => {
const [script, setScript] = useState(scriptContext)
const [scriptEl, setScriptEl] =
useState<Nullable<monacoEditor.editor.IStandaloneCodeEditor>>(null)
const [pendingSubmit, setPendingSubmit] =
useState<Nullable<PendingSubmit>>(null)

const instance = useSelector(connectedInstanceSelector)
const { isDangerousCommand } = useDatabaseEnvironment()
const { visualizations = [] } = useSelector(appPluginsSelector)
state = {
scriptEl,
Expand Down Expand Up @@ -235,13 +248,11 @@ const WBViewWrapper = () => {
setScript('')
}

const sourceValueSubmit = (
value: string = script,
commandId?: Nullable<string>,
executeParams: CodeButtonParams = { clearEditor: true },
const runSubmission = (
value: string,
commandId: Maybe<Nullable<string>>,
executeParams: CodeButtonParams,
) => {
if (state.loading || (!value && !script)) return

const lines = getMonacoLines(value)
const parsedParams: Maybe<CodeButtonParams> = getParsedParamsInQuery(value)

Expand All @@ -253,25 +264,83 @@ const WBViewWrapper = () => {
}
}

const sourceValueSubmit = (
value: string = script,
commandId?: Nullable<string>,
executeParams: CodeButtonParams = { clearEditor: true },
) => {
if (state.loading || (!value && !script)) return

const effectiveValue = value || script
const commands = getCommandsForExecution(effectiveValue)
const dangerousCommands = commands.filter((cmd) =>
isDangerousCommand(cmd.split(/\s+/)[0]),
)
Comment thread
cursor[bot] marked this conversation as resolved.
if (dangerousCommands.length > 0) {
setPendingSubmit({
value: effectiveValue,
commandId,
executeParams,
dangerousCommands,
})
return
}

runSubmission(effectiveValue, commandId, executeParams)
}

const handleConfirmPendingSubmit = () => {
if (!pendingSubmit) return
const submission = pendingSubmit
setPendingSubmit(null)
runSubmission(
submission.value,
submission.commandId,
submission.executeParams,
)
}

const confirmationText =
instance?.name || `${instance?.host}:${instance?.port}`

return (
<WBView
items={items}
clearing={clearing}
processing={processing}
isResultsLoaded={isLoaded}
script={script}
setScript={setScript}
setScriptEl={setScriptEl}
scrollDivRef={scrollDivRef}
activeMode={activeRunQueryMode}
onSubmit={sourceValueSubmit}
onQueryOpen={handleQueryOpen}
onQueryDelete={handleQueryDelete}
onAllQueriesDelete={handleAllQueriesDelete}
onQueryChangeMode={handleChangeQueryRunMode}
resultsMode={resultsMode}
onChangeGroupMode={handleChangeGroupMode}
/>
<>
<WBView
items={items}
clearing={clearing}
processing={processing}
isResultsLoaded={isLoaded}
script={script}
setScript={setScript}
setScriptEl={setScriptEl}
scrollDivRef={scrollDivRef}
activeMode={activeRunQueryMode}
onSubmit={sourceValueSubmit}
onQueryOpen={handleQueryOpen}
onQueryDelete={handleQueryDelete}
onAllQueriesDelete={handleAllQueriesDelete}
onQueryChangeMode={handleChangeQueryRunMode}
resultsMode={resultsMode}
onChangeGroupMode={handleChangeGroupMode}
/>
{pendingSubmit && (
<TypeToConfirmModal
title="Run dangerous commands?"
confirmationText={confirmationText}
actionDescription={
<>
You&apos;re about to run{' '}
<strong>{pendingSubmit.dangerousCommands.join(', ')}</strong>{' '}
against the production database{' '}
<strong>{confirmationText}</strong>.
</>
}
confirmButtonText="Run command"
onConfirm={handleConfirmPendingSubmit}
onCancel={() => setPendingSubmit(null)}
/>
)}
</>
)
}

Expand Down
Loading