Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates force password change flow to use Cypher first, falling back to dbms function call #1992

Merged
merged 4 commits into from
Feb 10, 2025
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
6 changes: 4 additions & 2 deletions src/browser/modules/Stream/Auth/ConnectionFormController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ import {
setActiveConnection,
updateConnection
} from 'shared/modules/connections/connectionsDuck'
import { AuthenticationMethod } from 'shared/modules/connections/connectionsDuck'
import { FORCE_CHANGE_PASSWORD } from 'shared/modules/cypher/cypherDuck'
import {
AuthenticationMethod,
FORCE_CHANGE_PASSWORD
} from 'shared/modules/connections/connectionsDuck'
import { shouldRetainConnectionCredentials } from 'shared/modules/dbMeta/dbMetaDuck'
import { CONNECTION_ID } from 'shared/modules/discovery/discoveryDuck'
import { fetchBrowserDiscoveryDataFromUrl } from 'shared/modules/discovery/discoveryHelpers'
Expand Down
313 changes: 312 additions & 1 deletion src/shared/modules/connections/connectionsDuck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@ import {
DONE as DISCOVERY_DONE,
updateDiscoveryConnection
} from 'shared/modules/discovery/discoveryDuck'
import forceResetPasswordQueryHelper, {
MultiDatabaseNotSupportedError
} from './forceResetPasswordQueryHelper'

jest.mock('services/bolt/bolt', () => {
return {
closeConnection: jest.fn(),
openConnection: jest.fn()
openConnection: jest.fn(),
directConnect: jest.fn()
}
})

Expand Down Expand Up @@ -457,3 +461,310 @@ describe('switchConnectionEpic', () => {
return p
})
})

describe('handleForcePasswordChangeEpic', () => {
const bus = createBus()
const epicMiddleware = createEpicMiddleware(
connections.handleForcePasswordChangeEpic
)
const mockStore = configureMockStore([
epicMiddleware,
createReduxMiddleware(bus)
])

let store: any

const $$responseChannel = 'test-channel'
const action = {
host: 'bolt://localhost:7687',
type: connections.FORCE_CHANGE_PASSWORD,
password: 'changeme',
newPassword: 'password1',
$$responseChannel
}

const executePasswordResetQuerySpy = jest.spyOn(
forceResetPasswordQueryHelper,
'executePasswordResetQuery'
)

const executeAlterCurrentUserQuerySpy = jest.spyOn(
forceResetPasswordQueryHelper,
'executeAlterCurrentUserQuery'
)

const executeCallChangePasswordQuerySpy = jest.spyOn(
forceResetPasswordQueryHelper,
'executeCallChangePasswordQuery'
)

const mockSessionClose = jest.fn()
const mockSessionExecuteWrite = jest.fn()

const mockDriver = {
session: jest.fn().mockReturnValue({
close: mockSessionClose,
executeWrite: mockSessionExecuteWrite
}),
close: jest.fn().mockReturnValue(true)
}

beforeAll(() => {
store = mockStore({})
})

beforeEach(() => {
;(bolt.directConnect as jest.Mock).mockResolvedValue(mockDriver)
})

afterEach(() => {
store.clearActions()
bus.reset()
jest.clearAllMocks()
})

test('handleForcePasswordChangeEpic resolves with an error if directConnect fails', () => {
// Given
const message = 'An error occurred.'
;(bolt.directConnect as jest.Mock).mockRejectedValue(new Error(message))

const p = new Promise<void>((resolve, reject) => {
bus.take($$responseChannel, currentAction => {
// Then
const actions = store.getActions()
try {
expect(actions).toEqual([action, currentAction])

expect(executeAlterCurrentUserQuerySpy).not.toHaveBeenCalled()

expect(executeCallChangePasswordQuerySpy).not.toHaveBeenCalled()

expect(executePasswordResetQuerySpy).not.toHaveBeenCalled()

expect(currentAction).toEqual({
error: expect.objectContaining({
message
}),
success: false,
type: $$responseChannel
})

resolve()

expect(mockDriver.close).not.toHaveBeenCalled()
expect(mockSessionClose).not.toHaveBeenCalled()
} catch (e) {
reject(e)
}
})
})

// When
epicMiddleware.replaceEpic(connections.handleForcePasswordChangeEpic)
store.clearActions()
store.dispatch(action)

// Return
return p
})

test('handleForcePasswordChangeEpic resolves when successfully executing cypher query', () => {
// Given
mockSessionExecuteWrite.mockResolvedValue(true)

const p = new Promise<void>((resolve, reject) => {
bus.take($$responseChannel, currentAction => {
// Then
const actions = store.getActions()
try {
expect(actions).toEqual([action, currentAction])

expect(executeAlterCurrentUserQuerySpy).toHaveBeenCalledTimes(1)

expect(executeCallChangePasswordQuerySpy).not.toHaveBeenCalled()

expect(executePasswordResetQuerySpy).toHaveBeenCalledTimes(1)

expect(executePasswordResetQuerySpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
parameters: { newPw: 'password1', oldPw: 'changeme' },
query: 'ALTER CURRENT USER SET PASSWORD FROM $oldPw TO $newPw'
}),
expect.anything(),
{ database: 'system' }
)

expect(currentAction).toEqual({
result: { meta: 'bolt://localhost:7687' },
success: true,
type: $$responseChannel
})

resolve()

expect(mockDriver.close).toHaveBeenCalledTimes(1)
expect(mockSessionClose).toHaveBeenCalledTimes(1)
} catch (e) {
reject(e)
}
})
})

// When
epicMiddleware.replaceEpic(connections.handleForcePasswordChangeEpic)
store.clearActions()
store.dispatch(action)

// Return
return p
})

test('handleForcePasswordChangeEpic resolves with an error if cypher query fails', () => {
// Given
const message = 'A password must be at least 8 characters.'
mockSessionExecuteWrite
.mockRejectedValueOnce(new Error(message))
.mockResolvedValue(true)

const p = new Promise<void>((resolve, reject) => {
bus.take($$responseChannel, currentAction => {
// Then
const actions = store.getActions()
try {
expect(actions).toEqual([action, currentAction])

expect(executeAlterCurrentUserQuerySpy).toHaveBeenCalledTimes(1)

expect(executeCallChangePasswordQuerySpy).not.toHaveBeenCalled()

expect(executePasswordResetQuerySpy).toHaveBeenCalledTimes(1)

expect(currentAction).toEqual({
error: expect.objectContaining({
message
}),
success: false,
type: $$responseChannel
})

resolve()

expect(mockDriver.close).toHaveBeenCalledTimes(1)
expect(mockSessionClose).toHaveBeenCalledTimes(1)
} catch (e) {
reject(e)
}
})
})

// When
epicMiddleware.replaceEpic(connections.handleForcePasswordChangeEpic)
store.clearActions()
store.dispatch(action)

// Return
return p
})

test('handleForcePasswordChangeEpic resolves when successfully falling back to dbms function call', () => {
// Given
mockSessionExecuteWrite
.mockRejectedValueOnce(new MultiDatabaseNotSupportedError())
.mockResolvedValue(true)

const p = new Promise<void>((resolve, reject) => {
bus.take($$responseChannel, currentAction => {
// Then
const actions = store.getActions()
try {
expect(actions).toEqual([action, currentAction])

expect(executeAlterCurrentUserQuerySpy).toHaveBeenCalledTimes(1)

expect(executeCallChangePasswordQuerySpy).toHaveBeenCalledTimes(1)

expect(executePasswordResetQuerySpy).toHaveBeenCalledTimes(2)

expect(executePasswordResetQuerySpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
parameters: { password: 'password1' },
query: 'CALL dbms.security.changePassword($password)'
}),
expect.anything(),
undefined
)

expect(currentAction).toEqual({
result: { meta: 'bolt://localhost:7687' },
success: true,
type: $$responseChannel
})

resolve()

expect(mockDriver.close).toHaveBeenCalledTimes(1)
expect(mockSessionClose).toHaveBeenCalledTimes(2)
} catch (e) {
reject(e)
}
})
})

// When
epicMiddleware.replaceEpic(connections.handleForcePasswordChangeEpic)
store.clearActions()
store.dispatch(action)

// Return
return p
})

test('handleForcePasswordChangeEpic resolves with an error if dbms function call fails', () => {
// Given
const message = 'A password must be at least 8 characters.'
mockSessionExecuteWrite
.mockRejectedValueOnce(new MultiDatabaseNotSupportedError())
.mockRejectedValue(new Error(message))

const p = new Promise<void>((resolve, reject) => {
bus.take($$responseChannel, currentAction => {
// Then
const actions = store.getActions()
try {
expect(actions).toEqual([action, currentAction])

expect(executeAlterCurrentUserQuerySpy).toHaveBeenCalledTimes(1)

expect(executeCallChangePasswordQuerySpy).toHaveBeenCalledTimes(1)

expect(executePasswordResetQuerySpy).toHaveBeenCalledTimes(2)

expect(currentAction).toEqual({
error: expect.objectContaining({
message
}),
success: false,
type: $$responseChannel
})

resolve()

expect(mockDriver.close).toHaveBeenCalledTimes(1)
expect(mockSessionClose).toHaveBeenCalledTimes(2)
} catch (e) {
reject(e)
}
})
})

// When
epicMiddleware.replaceEpic(connections.handleForcePasswordChangeEpic)
store.clearActions()
store.dispatch(action)

// Return
return p
})
})
Loading