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
28 changes: 18 additions & 10 deletions src/react/locations/CRRSetupWizard/CRRSetupWizard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
Expand Down Expand Up @@ -30,6 +30,12 @@ const fillValidForm = async () => {
await userEvent.type(destinationAccountName, 'dest-account');
};

const clickWhenEnabled = async (name: RegExp) => {
const button = screen.getByRole('button', { name });
await waitFor(() => expect(button).toBeEnabled());
await userEvent.click(button);
};

describe('CRRSetupWizard — Configure step', () => {
it('confirms the destination is reachable when the user clicks Check Connection', async () => {
server.use(
Expand All @@ -40,9 +46,11 @@ describe('CRRSetupWizard — Configure step', () => {
render(<CRRSetupWizard />, { wrapper: Wrapper });

await fillValidForm();
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
await clickWhenEnabled(/Check Connection/i);

expect(await screen.findByText(/Destination reachable/i)).toBeInTheDocument();
expect(await screen.findByText(/Connection established/i)).toBeInTheDocument();
expect(await screen.findByText('Connected')).toBeInTheDocument();
expect(screen.getByText(/ageless-valley/)).toBeInTheDocument();
});

it('surfaces the ARTESCA problem code when Check Connection is rejected', async () => {
Expand All @@ -65,9 +73,9 @@ describe('CRRSetupWizard — Configure step', () => {
render(<CRRSetupWizard />, { wrapper: Wrapper });

await fillValidForm();
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
await clickWhenEnabled(/Check Connection/i);

expect(await screen.findByText(/pasted certificate is not a valid PEM/i)).toBeInTheDocument();
expect(await screen.findByText(/The destination certificate is invalid/i)).toBeInTheDocument();
});

it('blocks the user on Configure with an error toast when the silent verify fails on Continue', async () => {
Expand All @@ -90,9 +98,9 @@ describe('CRRSetupWizard — Configure step', () => {
render(<CRRSetupWizard />, { wrapper: Wrapper });

await fillValidForm();
await userEvent.click(screen.getByRole('button', { name: /Continue/i }));
await clickWhenEnabled(/Continue/i);

expect(await screen.findByText(/destination cluster did not respond/i)).toBeInTheDocument();
expect(await screen.findByText(/Failed to reach the destination/i)).toBeInTheDocument();
});

it('opens the DNS fallback modal on unresolved hosts and retries with one IP applied to all of them', async () => {
Expand Down Expand Up @@ -123,16 +131,16 @@ describe('CRRSetupWizard — Configure step', () => {
render(<CRRSetupWizard />, { wrapper: Wrapper });

await fillValidForm();
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
await clickWhenEnabled(/Check Connection/i);

// The DNS failure surfaces the fallback modal listing every host that could not be resolved.
expect(await screen.findByText('• s3.dest.local')).toBeInTheDocument();
expect(screen.getByText('• iam.dest.local')).toBeInTheDocument();

await userEvent.type(screen.getByRole('textbox', { name: /Destination cluster IP/i }), '10.0.0.9');
await userEvent.click(screen.getByRole('button', { name: /Retry Connection/i }));
await clickWhenEnabled(/Retry Connection/i);

expect(await screen.findByText(/Destination reachable/i)).toBeInTheDocument();
expect(await screen.findByText(/Connection established/i)).toBeInTheDocument();
// The single IP fans out to an alias for each unresolved host.
expect(retryBody?.hostAliases).toEqual([
{ hostname: 's3.dest.local', ip: '10.0.0.9' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@ import { type ConfigureFormValues, configureResolver, defaultConfigureValues, to
export const CONFIGURE_STEP_INDEX = 0;

const errorCopy: Partial<Record<ProblemCode, string>> = {
DestinationUnreachable: 'The destination cluster did not respond.',
DestinationDnsResolutionFailed: 'One or more destination hostnames could not be resolved.',
DestinationCertificateInvalid: 'The pasted certificate is not a valid PEM bundle.',
DestinationAuthFailed: 'The destination refused these admin credentials.',
AssumeRoleFailed: 'The destination rejected the storage-manager role assumption.',
Unauthorized: 'Your session was rejected by the source cluster. Sign in again.',
Forbidden: 'You need the Storage Manager role to run this wizard.',
DestinationUnreachable: 'Failed to reach the destination. Check the URL and your network connection.',
DestinationDnsResolutionFailed: 'Failed to resolve the destination hostnames.',
DestinationCertificateInvalid: 'The destination certificate is invalid.',
DestinationAuthFailed: 'Failed to authenticate with the destination. Check your credentials.',
AssumeRoleFailed: 'Failed to assume the replication role on the destination.',
Unauthorized: 'Your session has expired. Sign in again.',
Forbidden: 'You are not authorized to configure replication.',
};

const errorMessage = (error: unknown): string => {
if (error instanceof ServiceError) {
const code = error.problem.code as ProblemCode | undefined;
return (code && errorCopy[code]) ?? error.problem.title ?? 'Connection to the destination failed.';
return (code && errorCopy[code]) ?? error.problem.title ?? 'Failed to reach the destination.';
}
return (error as Error)?.message ?? 'Connection to the destination failed.';
return (error as Error)?.message ?? 'Failed to reach the destination.';
};

const unresolvedHostsFrom = (error: unknown): string[] | null => {
Expand Down Expand Up @@ -66,7 +66,7 @@ export const ConfigureStep = () => {
handleSubmit,
getValues,
setValue,
trigger,
watch,
formState: { isValid },
} = formMethods;

Expand All @@ -89,27 +89,9 @@ export const ConfigureStep = () => {
response?.ok && response.mode === 'management-network' ? response.instanceName : undefined;

const onCheckConnection = async () => {
const valid = await trigger([
'connectionMode',
'url',
'baseDomain',
's3Endpoint',
'username',
'password',
'certificate',
]);
if (!valid) {
showToast({
open: true,
status: 'error',
message: 'Complete the destination fields before checking the connection',
});
return;
}
const body = toVerifyBody(getValues());
try {
await runVerify(body);
showToast({ open: true, status: 'success', message: 'Destination reachable' });
await runVerify(toVerifyBody(getValues()));
showToast({ open: true, status: 'success', message: 'Connection established' });
} catch (error) {
handleVerifyError(error);
}
Expand All @@ -130,6 +112,10 @@ export const ConfigureStep = () => {
}
});

const watchedValues = watch();
const isConnected = verify.isSuccess && lastVerifiedRef.current === JSON.stringify(toVerifyBody(watchedValues));
const connectedInstanceName = isConnected ? destinationInstanceNameFrom(verify.data) : undefined;

return (
<FormProvider {...formMethods}>
<DnsFallbackModal
Expand Down Expand Up @@ -162,7 +148,12 @@ export const ConfigureStep = () => {
}
>
<SourceSection />
<DestinationConnectionSection isCheckingConnection={verify.isLoading} onCheckConnection={onCheckConnection} />
<DestinationConnectionSection
isCheckingConnection={verify.isLoading}
onCheckConnection={onCheckConnection}
isConnected={isConnected}
connectedInstanceName={connectedInstanceName}
/>
<DestinationAccountSection />
<ReplicationSection />
</Form>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormGroup, FormSection, Radio, Stack, Wrap } from '@scality/core-ui';
import { FormGroup, FormSection, Icon, Radio, Stack, Text, Wrap } from '@scality/core-ui';
import { Button, Input } from '@scality/core-ui/dist/next';
import { Controller, useFormContext } from 'react-hook-form';
import { CertificateSection } from '../../../../ui-elements/CertificateSection';
Expand All @@ -7,19 +7,33 @@ import type { ConfigureFormValues } from './schema';
type Props = {
isCheckingConnection: boolean;
onCheckConnection: () => void;
isConnected: boolean;
connectedInstanceName?: string;
};

export const DestinationConnectionSection = ({ isCheckingConnection, onCheckConnection }: Props) => {
export const DestinationConnectionSection = ({
isCheckingConnection,
onCheckConnection,
isConnected,
connectedInstanceName,
}: Props) => {
const {
control,
register,
watch,
formState: { errors, touchedFields },
} = useFormContext<ConfigureFormValues>();
const connectionMode = watch('connectionMode');
const values = watch();
const connectionMode = values.connectionMode;
const errorIfTouched = (field: keyof ConfigureFormValues) =>
touchedFields[field] ? errors[field]?.message : undefined;

const connectionFields: (keyof ConfigureFormValues)[] =
connectionMode === 'data-network'
? ['baseDomain', 's3Endpoint', 'username', 'password', 'certificate']
: ['url', 'username', 'password', 'certificate'];
const connectionValid = connectionFields.every((field) => Boolean(values[field]) && !errors[field]);

return (
<FormSection forceLabelWidth={280} title={{ name: 'Destination Connection' }}>
<FormGroup
Expand Down Expand Up @@ -113,12 +127,24 @@ export const DestinationConnectionSection = ({ isCheckingConnection, onCheckConn
/>
<CertificateSection name="certificate" />
<Wrap width="100%">
<div />
{isConnected ? (
<Stack gap="r8">
<Icon name="Check-circle" color="statusHealthy" />
<Text>
<Text isEmphazed>Connected</Text>
{connectedInstanceName ? `: ${connectedInstanceName}` : ''}
</Text>
</Stack>
) : (
<div />
)}
<Button
type="button"
variant="secondary"
label="Check Connection"
isLoading={isCheckingConnection}
disabled={!connectionValid}
tooltip={connectionValid ? undefined : { overlay: 'Fill in the destination fields to check the connection' }}
onClick={onCheckConnection}
/>
</Wrap>
Expand Down
Loading