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
5 changes: 5 additions & 0 deletions .changeset/twenty-radios-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smartcontractkit/operator-ui': minor
---

Added support for the display and deletion of OCR version 2 (OCR2) keys
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ tsconfig.tsbuildinfo
.npmrc
assets
yarn-error.log

# OS specific
.DS_Store
94 changes: 94 additions & 0 deletions src/hooks/useQueryErrorHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react'

import { ApolloError } from '@apollo/client'
import { GraphQLError } from 'graphql'
import { Route } from 'react-router-dom'
import { renderWithRouter, screen } from 'support/test-utils'
import { getAuthentication } from 'utils/storage'

import Notifications from 'pages/Notifications'
import { useQueryErrorHandler } from 'hooks/useQueryErrorHandler'

const { getByText } = screen

const StubComponent = ({ mockError }: { mockError?: unknown }) => {
const { handleQueryError } = useQueryErrorHandler()

React.useEffect(() => {
handleQueryError(mockError)
}, [mockError, handleQueryError])

return null
}

function renderComponent(mockError?: unknown) {
renderWithRouter(
<>
<Notifications />
<Route exact path="/">
<StubComponent mockError={mockError} />
</Route>

<Route exact path="/signin">
Redirect Success
</Route>
</>,
)
}

describe('useQueryErrorHandler', () => {
it('renders an empty component if error undefined', () => {
renderComponent()

expect(document.documentElement).toHaveTextContent('')
})

it('renders the apollo error message', () => {
const graphQLErrors = [new GraphQLError('GraphQL error')]
const errorMessage = 'Something went wrong'
const apolloError = new ApolloError({
graphQLErrors,
errorMessage,
})

renderComponent(apolloError)

expect(getByText('Something went wrong')).toBeInTheDocument()
})

it('redirects an authenticated error', () => {
const graphQLErrors = [
new GraphQLError(
'Unauthorized',
undefined,
undefined,
undefined,
undefined,
undefined,
{ code: 'UNAUTHORIZED' },
),
]
const errorMessage = 'Something went wrong'
const apolloError = new ApolloError({
graphQLErrors,
errorMessage,
})

renderComponent(apolloError)

expect(getByText('Redirect Success')).toBeInTheDocument()
expect(getAuthentication()).toEqual({ allowed: false })
})

it('renders the message in an alert when it is a simple error', () => {
renderComponent(new Error('Something went wrong'))

expect(getByText('Something went wrong')).toBeInTheDocument()
})

it('renders a generic message in an alert as a default', () => {
renderComponent('generic message') // A string type is not handled and falls to the default

expect(getByText('An error occurred')).toBeInTheDocument()
})
})
44 changes: 44 additions & 0 deletions src/hooks/useQueryErrorHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react'
import { useHistory } from 'react-router-dom'
import { useDispatch } from 'react-redux'

import { notifyErrorMsg } from 'actionCreators'
import { ApolloError } from '@apollo/client'
import { receiveSignoutSuccess } from 'actionCreators'
/**
* Handles an unknown error which is caught from a
* query operation. If the error returned is an authentication error, it
* signs the user out and redirects them to the sign-in page, otherwise it
* displays an alert with the error message.
*/
export const useQueryErrorHandler = () => {
const [error, handleQueryError] = React.useState<unknown>()
const history = useHistory()
const dispatch = useDispatch()

React.useEffect(() => {
if (!error) {
return
}

if (error instanceof ApolloError) {
// Check for an authentication error and logout
for (const gqlError of error.graphQLErrors) {
if (gqlError.extensions?.code == 'UNAUTHORIZED') {
dispatch(
notifyErrorMsg(
'Unauthorized, please log in with proper credentials',
),
)
dispatch(receiveSignoutSuccess())
history.push('/signin')

return
}
}
}
dispatch(notifyErrorMsg((error as Error).message || 'An error occurred'))
}, [dispatch, error, history])

return { handleQueryError }
}
5 changes: 5 additions & 0 deletions src/screens/KeyManagement/KeyManagementView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Content from 'components/Content'
import { EVMAccounts } from './EVMAccounts'
import { CSAKeys } from './CSAKeys'
import { OCRKeys } from './OCRKeys'
import { OCR2Keys } from './OCR2Keys'
import { P2PKeys } from './P2PKeys'

interface Props {
Expand All @@ -22,6 +23,10 @@ export const KeyManagementView: React.FC<Props> = ({
<OCRKeys />
</Grid>

<Grid item xs={12}>
<OCR2Keys />
</Grid>

<Grid item xs={12}>
<P2PKeys />
</Grid>
Expand Down
57 changes: 57 additions & 0 deletions src/screens/KeyManagement/OCR2KeyBundleRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as React from 'react'

import { render, screen } from 'support/test-utils'

import { buildOCR2KeyBundle } from 'support/factories/gql/fetchOCR2KeyBundles'
import { OCR2KeyBundleRow } from './OCR2KeyBundleRow'
import userEvent from '@testing-library/user-event'

const { getByRole, queryByText } = screen

describe('OCR2KeyBundleRow', () => {
let handleDelete: jest.Mock

beforeEach(() => {
handleDelete = jest.fn()
})

function renderComponent(bundle: Ocr2KeyBundlesPayload_ResultsFields) {
render(
<table>
<tbody>
<OCR2KeyBundleRow bundle={bundle} onDelete={handleDelete} />
</tbody>
</table>,
)
}

it('renders a row', () => {
const bundle = buildOCR2KeyBundle()

renderComponent(bundle)

expect(queryByText(`Key ID: ${bundle.id}`)).toBeInTheDocument()
expect(
queryByText(`Chain Type: ${bundle.chainType}`),
).toBeInTheDocument()
expect(
queryByText(`Config Public Key: ${bundle.configPublicKey}`),
).toBeInTheDocument()
expect(
queryByText(`On-Chain Public Key: ${bundle.onChainPublicKey}`),
).toBeInTheDocument()
expect(
queryByText(`Off-Chain Public Key: ${bundle.offChainPublicKey}`),
).toBeInTheDocument()
})

it('calls delete', () => {
const bundle = buildOCR2KeyBundle()

renderComponent(bundle)

userEvent.click(getByRole('button', { name: /delete/i }))

expect(handleDelete).toHaveBeenCalled()
})
})
44 changes: 44 additions & 0 deletions src/screens/KeyManagement/OCR2KeyBundleRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react'

import Button from 'src/components/Button'
import TableCell from '@material-ui/core/TableCell'
import TableRow from '@material-ui/core/TableRow'

import { KeyBundle } from './KeyBundle'
import { CopyIconButton } from 'src/components/Copy/CopyIconButton'

interface Props {
bundle: Ocr2KeyBundlesPayload_ResultsFields
onDelete: () => void
}

/**
* This row follows the form and structure of OCRKeyBundleRow but
* uses the new data for keys from OCR2
*/
export const OCR2KeyBundleRow: React.FC<Props> = ({ bundle, onDelete }) => {
return (
<TableRow hover>
<TableCell>
<KeyBundle
primary={
<b>
Key ID: {bundle.id} <CopyIconButton data={bundle.id} />
</b>
}
secondary={[
<>Chain Type: {bundle.chainType}</>,
<>Config Public Key: {bundle.configPublicKey}</>,
<>On-Chain Public Key: {bundle.onChainPublicKey}</>,
<>Off-Chain Public Key: {bundle.offChainPublicKey}</>,
]}
/>
</TableCell>
<TableCell align="right">
<Button onClick={onDelete} variant="danger" size="medium">
Delete
</Button>
</TableCell>
</TableRow>
)
}
122 changes: 122 additions & 0 deletions src/screens/KeyManagement/OCR2Keys.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as React from 'react'

import { GraphQLError } from 'graphql'
import {
renderWithRouter,
screen,
waitForElementToBeRemoved,
} from 'support/test-utils'
import { MockedProvider, MockedResponse } from '@apollo/client/testing'
import userEvent from '@testing-library/user-event'

import {
OCR2Keys,
DELETE_OCR2_KEY_BUNDLE_MUTATION,
} from './OCR2Keys'
import {
buildOCR2KeyBundle,
buildOCR2KeyBundles,
} from 'support/factories/gql/fetchOCR2KeyBundles'
import Notifications from 'pages/Notifications'
import { OCR2_KEY_BUNDLES_QUERY } from 'src/hooks/queries/useOCR2KeysQuery'
import { waitForLoading } from 'support/test-helpers/wait'

const { findByText, getByRole, queryByText } = screen

function renderComponent(mocks: MockedResponse[]) {
renderWithRouter(
<>
<Notifications />
<MockedProvider mocks={mocks} addTypename={false}>
<OCR2Keys />
</MockedProvider>
</>,
)
}

function fetchOCR2KeyBundlesQuery(
bundles: ReadonlyArray<Ocr2KeyBundlesPayload_ResultsFields>,
) {
return {
request: {
query: OCR2_KEY_BUNDLES_QUERY,
},
result: {
data: {
ocr2KeyBundles: {
results: bundles,
},
},
},
}
}

describe('OCR2Keys', () => {
it('renders the page', async () => {
const payload = buildOCR2KeyBundles()
const mocks: MockedResponse[] = [fetchOCR2KeyBundlesQuery(payload)]

renderComponent(mocks)

await waitForLoading()

expect(await findByText(`Key ID: ${payload[0].id}`)).toBeInTheDocument()
})

it('renders GQL query errors', async () => {
const mocks: MockedResponse[] = [
{
request: {
query: OCR2_KEY_BUNDLES_QUERY,
},
result: {
errors: [new GraphQLError('Error!')],
},
},
]

renderComponent(mocks)

expect(await findByText('Error!')).toBeInTheDocument()
})

it('deletes an OCR2 Key Bundle', async () => {
const payload = buildOCR2KeyBundle()

const mocks: MockedResponse[] = [
fetchOCR2KeyBundlesQuery([payload]),
{
request: {
query: DELETE_OCR2_KEY_BUNDLE_MUTATION,
variables: { id: payload.id },
},
result: {
data: {
deleteOCR2KeyBundle: {
__typename: 'DeleteOCR2KeyBundleSuccess',
bundle: payload,
},
},
},
},
fetchOCR2KeyBundlesQuery([]),
]

renderComponent(mocks)

expect(await findByText(`Key ID: ${payload.id}`)).toBeInTheDocument()

userEvent.click(getByRole('button', { name: /delete/i }))
userEvent.click(getByRole('button', { name: /confirm/i }))

await waitForElementToBeRemoved(getByRole('dialog'))

expect(
await findByText(
'Successfully deleted Off-ChainReporting Key Bundle Key',
),
).toBeInTheDocument()

expect(queryByText(`Key ID: ${payload.id}`)).toBeNull()
})
})
Loading