CreateImageWizard: Implement user-friendly error messages

Puts the error messages in one place - LabelInput.tsx
This commit is contained in:
Florian Schüller 2025-05-12 12:48:24 +02:00 committed by Klara Simickova
parent 3f35101f68
commit ccfdb49db2
11 changed files with 156 additions and 97 deletions

View file

@ -63,19 +63,29 @@ test('Create a blueprint with Firewall customization', async ({
await test.step('Select and incorrectly fill the ports in Firewall step', async () => {
await frame.getByPlaceholder('Add ports').fill('x');
await frame.getByRole('button', { name: 'Add ports' }).click();
await expect(frame.getByText('Invalid format.').nth(0)).toBeVisible();
await expect(
frame
.getByText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp'
)
.nth(0)
).toBeVisible();
});
await test.step('Select and incorrectly fill the disabled services in Firewall step', async () => {
await frame.getByPlaceholder('Add disabled service').fill('1');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await expect(frame.getByText('Invalid format.').nth(1)).toBeVisible();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(0)
).toBeVisible();
});
await test.step('Select and incorrectly fill the enabled services in Firewall step', async () => {
await frame.getByPlaceholder('Add enabled service').fill('ťčš');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await expect(frame.getByText('Invalid format.').nth(2)).toBeVisible();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(1)
).toBeVisible();
});
await test.step('Fill the BP details', async () => {

View file

@ -49,7 +49,11 @@ test('Create a blueprint with Kernel customization', async ({
.getByPlaceholder('Add kernel argument')
.fill('invalid/argument');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await expect(frame.getByText('Invalid format.')).toBeVisible();
await expect(
frame.getByText(
'Expected format: <kernel-argument>. Example: console=tty0'
)
).toBeVisible();
await frame.getByPlaceholder('Select kernel package').fill('new-package');
await frame
.getByRole('option', { name: 'Custom kernel package "new-' })

View file

@ -64,15 +64,21 @@ test('Create a blueprint with Systemd customization', async ({
await test.step('Select and incorrectly fill all of the service fields', async () => {
await frame.getByPlaceholder('Add disabled service').fill('&&');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await expect(frame.getByText('Invalid format.').nth(0)).toBeVisible();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(0)
).toBeVisible();
await frame.getByPlaceholder('Add enabled service').fill('áá');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await expect(frame.getByText('Invalid format.').nth(1)).toBeVisible();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(1)
).toBeVisible();
await frame.getByPlaceholder('Add masked service').fill('78');
await frame.getByRole('button', { name: 'Add masked service' }).click();
await expect(frame.getByText('Invalid format.').nth(2)).toBeVisible();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(2)
).toBeVisible();
});
await test.step('Fill the BP details', async () => {

View file

@ -55,7 +55,11 @@ test('Create a blueprint with Timezone customization', async ({
await expect(frame.getByText('NTP server already exists.')).toBeVisible();
await frame.getByPlaceholder('Add NTP servers').fill('xxxx');
await frame.getByRole('button', { name: 'Add NTP server' }).click();
await expect(frame.getByText('Invalid format.')).toBeVisible();
await expect(
frame
.getByText('Expected format: <ntp-server>. Example: time.redhat.com')
.nth(0)
).toBeVisible();
await frame.getByPlaceholder('Add NTP servers').fill('0.cz.pool.ntp.org');
await frame.getByRole('button', { name: 'Add NTP server' }).click();
await expect(frame.getByText('0.cz.pool.ntp.org')).toBeVisible();

View file

@ -71,7 +71,44 @@ const LabelInput = ({
}
if (!validator(value)) {
setOnStepInputErrorText('Invalid format.');
switch (fieldName) {
case 'ports':
setOnStepInputErrorText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp'
);
break;
case 'kernelAppend':
setOnStepInputErrorText(
'Expected format: <kernel-argument>. Example: console=tty0'
);
break;
case 'kernelName':
setOnStepInputErrorText(
'Expected format: <kernel-name>. Example: kernel-5.14.0-284.11.1.el9_2.x86_64'
);
break;
case 'groups':
setOnStepInputErrorText(
'Expected format: <group-name>. Example: admin'
);
break;
case 'ntpServers':
setOnStepInputErrorText(
'Expected format: <ntp-server>. Example: time.redhat.com'
);
break;
case 'enabledSystemdServices':
case 'disabledSystemdServices':
case 'maskedSystemdServices':
case 'disabledServices':
case 'enabledServices':
setOnStepInputErrorText(
'Expected format: <service-name>. Example: sshd'
);
break;
default:
setOnStepInputErrorText('Invalid format.');
}
return;
}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { CheckCircleIcon } from '@patternfly/react-icons';
import { jwtDecode, JwtPayload } from 'jwt-decode';
import { jwtDecode } from 'jwt-decode';
import { getListOfDuplicates } from './getListOfDuplicates';
@ -30,6 +30,7 @@ import {
selectLanguages,
selectKeyboard,
selectTimezone,
selectSatelliteCaCertificate,
selectSatelliteRegistrationCommand,
selectImageTypes,
UserWithAdditionalInfo,
@ -45,11 +46,11 @@ import {
isMountpointMinSizeValid,
isSnapshotValid,
isHostnameValid,
isKernelNameValid,
isUserNameValid,
isSshKeyValid,
isNtpServerValid,
isKernelArgumentValid,
isKernelNameValid,
isPortValid,
isServiceValid,
isUserGroupValid,
@ -113,74 +114,13 @@ type ValidationState = {
ruleCharacters: HelperTextVariant;
};
function tokenValidityRemaining(expireTimeInSeconds: number): number {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
return expireTimeInSeconds - currentTimeSeconds;
}
function decodeToken(token: string): JwtPayload | undefined {
try {
const decoded = jwtDecode(token) as { exp?: number };
return decoded;
} catch {
return undefined;
}
}
function getExpirationString(totalSeconds: number): string | undefined {
const hours = Math.floor(totalSeconds / 3600);
if (hours > 25) {
return undefined;
}
if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''}`;
}
return 'less than an hour';
}
export function validateSatelliteToken(
registrationCommand: string | undefined
) {
const errors: Record<string, string> = {};
if (registrationCommand === '') {
errors.command = 'No registration command for Satellite registration';
return errors;
}
const match = registrationCommand?.match(/Bearer\s+([\w-]+\.[\w-]+\.[\w-]+)/);
if (!match || match.length < 2) {
errors.command = 'Invalid or missing token';
return errors;
}
const satelliteToken = decodeToken(match[1]);
if (satelliteToken === undefined) {
errors.command = 'Invalid or missing token';
return errors;
}
if (satelliteToken.exp) {
const tokenRemainingS = tokenValidityRemaining(satelliteToken.exp);
if (tokenRemainingS <= 0) {
errors.command = `The token is expired. Check out the Satellite documentation to extend the token lifetime.`;
return errors;
}
const expirationString = getExpirationString(tokenRemainingS);
if (expirationString !== undefined) {
errors.expired = `The token expires in ${expirationString}. Check out the Satellite documentation to extend the token lifetime.`;
}
}
return errors;
}
export function useRegistrationValidation(): StepValidation {
const registrationType = useAppSelector(selectRegistrationType);
const activationKey = useAppSelector(selectActivationKey);
const registrationCommand = useAppSelector(
selectSatelliteRegistrationCommand
);
const caCertificate = useAppSelector(selectSatelliteCaCertificate);
const { isFetching: isFetchingKeyInfo, isError: isErrorKeyInfo } =
useShowActivationKeyQuery(
@ -190,11 +130,7 @@ export function useRegistrationValidation(): StepValidation {
}
);
if (
registrationType !== 'register-later' &&
registrationType !== 'register-satellite' &&
!activationKey
) {
if (registrationType !== 'register-later' && !activationKey) {
return {
errors: { activationKey: 'No activation key selected' },
disabledNext: true,
@ -203,7 +139,6 @@ export function useRegistrationValidation(): StepValidation {
if (
registrationType !== 'register-later' &&
registrationType !== 'register-satellite' &&
activationKey &&
(isFetchingKeyInfo || isErrorKeyInfo)
) {
@ -215,14 +150,50 @@ export function useRegistrationValidation(): StepValidation {
if (registrationType === 'register-satellite') {
const errors = {};
const tokenErrors = validateSatelliteToken(registrationCommand);
Object.assign(errors, tokenErrors);
if (caCertificate === '') {
Object.assign(errors, {
certificate:
'Valid certificate must be present if you are registering Satellite.',
});
}
if (registrationCommand === '' || !registrationCommand) {
Object.assign(errors, {
command: 'No registration command for Satellite registration',
});
}
try {
const match = registrationCommand?.match(
/Bearer\s+([\w-]+\.[\w-]+\.[\w-]+)/
);
if (!match) {
Object.assign(errors, { command: 'Invalid or missing token' });
} else {
const token = match[1];
const decoded = jwtDecode(token);
if (decoded.exp) {
const currentTimeSeconds = Date.now() / 1000;
const dayInSeconds = 86400;
if (decoded.exp < currentTimeSeconds + dayInSeconds) {
const expirationDate = new Date(decoded.exp * 1000);
Object.assign(errors, {
expired:
'The token is already expired or will expire by next day. Expiration date: ' +
expirationDate,
});
return {
errors: errors,
disabledNext: caCertificate === undefined,
};
}
}
}
} catch {
Object.assign(errors, { command: 'Invalid or missing token' });
}
return {
errors: errors,
disabledNext:
Object.keys(errors).some((key) => key !== 'expired') ||
!registrationCommand,
Object.keys(errors).length > 0 || caCertificate === undefined,
};
}

View file

@ -555,9 +555,6 @@ describe('Import modal', () => {
await screen.findByPlaceholderText('Paste your public SSH key')
)
);
expect(
await screen.findByText(/Invalid user groups: 0000/)
).toBeInTheDocument();
await waitFor(() =>
user.click(screen.getByRole('button', { name: /close 0000/i }))
);

View file

@ -120,17 +120,27 @@ describe('Step Firewall', () => {
test('port in an invalid format cannot be added', async () => {
await renderCreateMode();
await goToFirewallStep();
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
expect(
screen.queryByText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp'
)
).not.toBeInTheDocument();
await addPort('00:wrongFormat');
await screen.findByText('Invalid format.');
await screen.findByText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp'
);
});
test('service in an invalid format cannot be added', async () => {
await renderCreateMode();
await goToFirewallStep();
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
expect(
screen.queryByText('Expected format: <service-name>. Example: sshd')
).not.toBeInTheDocument();
await addPort('wrong--service');
await screen.findByText('Invalid format.');
await screen.findByText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp'
);
});
test('revisit step button on Review works', async () => {

View file

@ -438,7 +438,7 @@ describe('Registration request generated correctly', () => {
);
const expiredTokenHelper = await screen.findByText(
/The token is expired./i
/The token is already expired or will expire by next day./i
);
await waitFor(() => expect(expiredTokenHelper).toBeInTheDocument());

View file

@ -179,20 +179,34 @@ describe('Step Services', () => {
// Enabled services input
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
await addEnabledService('-------');
expect(await screen.findByText('Invalid format.')).toBeInTheDocument();
expect(
await screen.findByText('Expected format: <service-name>. Example: sshd')
).toBeInTheDocument();
await waitFor(() => user.click(clearInputButtons[0]));
// Disabled services input
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
await addDisabledService('-------');
expect(await screen.findByText('Invalid format.')).toBeInTheDocument();
expect(
await screen.findByText('Expected format: <service-name>. Example: sshd')
).toBeInTheDocument();
await waitFor(() => user.click(clearInputButtons[1]));
// Masked services input
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
await addMaskedService('-------');
expect(await screen.findByText('Invalid format.')).toBeInTheDocument();
expect(
await screen.findByText('Expected format: <service-name>. Example: sshd')
).toBeInTheDocument();
await waitFor(() => user.click(clearInputButtons[2]));
// Enabled services input
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
await addEnabledService('-------');
expect(
await screen.findByText('Expected format: <service-name>. Example: sshd')
).toBeInTheDocument();
await waitFor(() => user.click(clearInputButtons[0]));
});
test('services from OpenSCAP get added correctly and cannot be removed', async () => {

View file

@ -162,9 +162,15 @@ describe('Step Timezone', () => {
test('NTP server in an invalid format cannot be added', async () => {
await renderCreateMode();
await goToTimezoneStep();
expect(screen.queryByText('Invalid format.')).not.toBeInTheDocument();
expect(
screen.queryByText(
'Expected format: <ntp-server>. Example: time.redhat.com'
)
).not.toBeInTheDocument();
await addNtpServerViaKeyDown('this is not NTP server');
await screen.findByText('Invalid format.');
await screen.findByText(
'Expected format: <ntp-server>. Example: time.redhat.com'
);
});
test('revisit step button on Review works', async () => {