Wizard: Add inputs for disabled and enabled services

This adds inputs for disabled and enabled systemd services. New tests are also added.
This commit is contained in:
regexowl 2025-01-28 10:07:46 +01:00 committed by Klara Simickova
parent 3cd3aa0176
commit 1e0cf96457
4 changed files with 340 additions and 1 deletions

View file

@ -0,0 +1,87 @@
import React from 'react';
import { FormGroup } from '@patternfly/react-core';
import { useAppSelector } from '../../../../../store/hooks';
import { useGetOscapCustomizationsQuery } from '../../../../../store/imageBuilderApi';
import {
addDisabledService,
addEnabledService,
removeDisabledService,
removeEnabledService,
selectComplianceProfileID,
selectDistribution,
selectServices,
} from '../../../../../store/wizardSlice';
import ChippingInput from '../../../ChippingInput';
import { isServiceValid } from '../../../validators';
const ServicesInput = () => {
const disabledServices = useAppSelector(selectServices).disabled;
const maskedServices = useAppSelector(selectServices).masked;
const enabledServices = useAppSelector(selectServices).enabled;
const release = useAppSelector(selectDistribution);
const complianceProfileID = useAppSelector(selectComplianceProfileID);
const { data: oscapProfileInfo } = useGetOscapCustomizationsQuery(
{
distribution: release,
// @ts-ignore if complianceProfileID is undefined the query is going to get skipped, so it's safe here to ignore the linter here
profile: complianceProfileID,
},
{
skip: !complianceProfileID,
}
);
const disabledAndMaskedRequiredByOpenSCAP = disabledServices
.concat(maskedServices)
.filter(
(service) =>
oscapProfileInfo?.services?.disabled?.includes(service) ||
oscapProfileInfo?.services?.masked?.includes(service)
);
const enabledRequiredByOpenSCAP = enabledServices.filter((service) =>
oscapProfileInfo?.services?.enabled?.includes(service)
);
return (
<>
<FormGroup isRequired={false} label="Disabled services">
<ChippingInput
ariaLabel="Add disabled service"
placeholder="Add disabled service"
validator={isServiceValid}
list={disabledServices
.concat(maskedServices)
.filter(
(service) =>
!disabledAndMaskedRequiredByOpenSCAP.includes(service)
)}
requiredList={disabledAndMaskedRequiredByOpenSCAP}
item="Disabled service"
addAction={addDisabledService}
removeAction={removeDisabledService}
/>
</FormGroup>
<FormGroup isRequired={false} label="Enabled services">
<ChippingInput
ariaLabel="Add enabled service"
placeholder="Add enabled service"
validator={isServiceValid}
list={enabledServices.filter(
(service) => !enabledRequiredByOpenSCAP.includes(service)
)}
requiredList={enabledRequiredByOpenSCAP}
item="Enabled service"
addAction={addEnabledService}
removeAction={removeEnabledService}
/>
</FormGroup>
</>
);
};
export default ServicesInput;

View file

@ -2,6 +2,8 @@ import React from 'react';
import { Text, Form, Title } from '@patternfly/react-core';
import ServicesInput from './components/ServicesInputs';
const ServicesStep = () => {
return (
<Form>
@ -9,6 +11,7 @@ const ServicesStep = () => {
Systemd services
</Title>
<Text>Enable and disable systemd services.</Text>
<ServicesInput />
</Form>
);
};

View file

@ -816,12 +816,57 @@ export const wizardSlice = createSlice({
changeEnabledServices: (state, action: PayloadAction<string[]>) => {
state.services.enabled = action.payload;
},
addEnabledService: (state, action: PayloadAction<string>) => {
if (
!state.services.enabled.some((service) => service === action.payload)
) {
state.services.enabled.push(action.payload);
}
},
removeEnabledService: (state, action: PayloadAction<string>) => {
state.services.enabled.splice(
state.services.enabled.findIndex(
(service) => service === action.payload
),
1
);
},
changeMaskedServices: (state, action: PayloadAction<string[]>) => {
state.services.masked = action.payload;
},
addMaskedService: (state, action: PayloadAction<string>) => {
if (
!state.services.masked.some((service) => service === action.payload)
) {
state.services.masked.push(action.payload);
}
},
removeMaskedService: (state, action: PayloadAction<string>) => {
state.services.masked.splice(
state.services.masked.findIndex(
(service) => service === action.payload
),
1
);
},
changeDisabledServices: (state, action: PayloadAction<string[]>) => {
state.services.disabled = action.payload;
},
addDisabledService: (state, action: PayloadAction<string>) => {
if (
!state.services.disabled.some((service) => service === action.payload)
) {
state.services.disabled.push(action.payload);
}
},
removeDisabledService: (state, action: PayloadAction<string>) => {
state.services.disabled.splice(
state.services.disabled.findIndex(
(service) => service === action.payload
),
1
);
},
changeKernelName: (state, action: PayloadAction<string>) => {
state.kernel.name = action.payload;
},
@ -1014,8 +1059,14 @@ export const {
loadWizardState,
setFirstBootScript,
changeEnabledServices,
addEnabledService,
removeEnabledService,
changeMaskedServices,
addMaskedService,
removeMaskedService,
changeDisabledServices,
addDisabledService,
removeDisabledService,
changeKernelName,
addKernelArg,
removeKernelArg,

View file

@ -2,9 +2,14 @@ import type { Router as RemixRouter } from '@remix-run/router';
import { screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { CREATE_BLUEPRINT } from '../../../../../constants';
import {
blueprintRequest,
clickBack,
clickNext,
enterBlueprintName,
interceptBlueprintRequest,
openAndDismissSaveAndBuildModal,
verifyCancelButton,
} from '../../wizardTestUtils';
import { clickRegisterLater, renderCreateMode } from '../../wizardTestUtils';
@ -33,6 +38,75 @@ const goToServicesStep = async () => {
await clickNext(); // Services
};
const goToOpenSCAPStep = async () => {
const user = userEvent.setup();
const guestImageCheckBox = await screen.findByRole('checkbox', {
name: /virtualization guest image checkbox/i,
});
await waitFor(() => user.click(guestImageCheckBox));
await clickNext(); // Registration
await clickRegisterLater();
await clickNext(); // OpenSCAP
};
const goFromOpenSCAPToServices = async () => {
await clickNext(); // File system configuration
await clickNext(); // Snapshots
await clickNext(); // Custom repositories
await clickNext(); // Additional packages
await clickNext(); // Users
await clickNext(); // Timezone
await clickNext(); // Locale
await clickNext(); // Hostname
await clickNext(); // Kernel
await clickNext(); // Firewall
await clickNext(); // Services
};
const goToReviewStep = async () => {
await clickNext(); // First boot script
await clickNext(); // Details
await enterBlueprintName();
await clickNext(); // Review
};
const addDisabledService = async (service: string) => {
const user = userEvent.setup();
const disabledServiceInput = await screen.findByPlaceholderText(
'Add disabled service'
);
await waitFor(() => user.type(disabledServiceInput, service.concat(' ')));
};
const addEnabledService = async (service: string) => {
const user = userEvent.setup();
const enabledServiceInput = await screen.findByPlaceholderText(
'Add enabled service'
);
await waitFor(() => user.type(enabledServiceInput, service.concat(' ')));
};
const removeService = async (service: string) => {
const user = userEvent.setup();
const removeServiceButton = await screen.findByRole('button', {
name: `close ${service}`,
});
await waitFor(() => user.click(removeServiceButton));
};
const selectProfile = async () => {
const user = userEvent.setup();
const selectProfileDropdown = await screen.findByRole('textbox', {
name: /select a profile/i,
});
await waitFor(() => user.click(selectProfileDropdown));
const cis1Profile = await screen.findByText(
/CIS Red Hat Enterprise Linux 8 Benchmark for Level 1 - Workstation/i
);
await waitFor(() => user.click(cis1Profile));
};
describe('Step Services', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -60,8 +134,132 @@ describe('Step Services', () => {
await goToServicesStep();
await verifyCancelButton(router);
});
test('services can be added and removed', async () => {
await renderCreateMode();
await goToServicesStep();
await addDisabledService('telnet');
await addDisabledService('https');
await removeService('telnet');
expect(screen.queryByText('telnet')).not.toBeInTheDocument();
});
test('services from OpenSCAP get added correctly and cannot be removed', async () => {
await renderCreateMode();
await goToOpenSCAPStep();
await selectProfile();
await goFromOpenSCAPToServices();
await screen.findAllByText('Required by OpenSCAP');
// disabled services
await screen.findByText('nfs-server');
await screen.findByText('emacs-service');
expect(
screen.queryByRole('button', { name: /close nfs-server/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /close emacs-service/i })
).not.toBeInTheDocument();
// enabled services
await screen.findByText('crond');
await screen.findByText('neovim-service');
expect(
screen.queryByRole('button', { name: /close crond/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /close neovim-service/i })
).not.toBeInTheDocument();
});
});
describe('Services request generated correctly', () => {
beforeEach(async () => {
vi.clearAllMocks();
});
test('with services', async () => {
await renderCreateMode();
await goToServicesStep();
await addDisabledService('telnet');
await addEnabledService('httpd');
await goToReviewStep();
// informational modal pops up in the first test only as it's tied
// to a 'imageBuilder.saveAndBuildModalSeen' variable in localStorage
await openAndDismissSaveAndBuildModal();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
services: {
disabled: ['telnet'],
enabled: ['httpd'],
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with added and removed services', async () => {
await renderCreateMode();
await goToServicesStep();
await addDisabledService('telnet');
await addEnabledService('httpd');
await removeService('telnet');
await removeService('httpd');
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with OpenSCAP profile that includes services', async () => {
await renderCreateMode();
await goToOpenSCAPStep();
await selectProfile();
await goFromOpenSCAPToServices();
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
filesystem: [
{
min_size: 10737418240,
mountpoint: '/',
},
{ min_size: 1073741824, mountpoint: '/tmp' },
{ min_size: 1073741824, mountpoint: '/home' },
],
openscap: {
profile_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l1',
},
packages: ['aide', 'neovim'],
kernel: {
append: 'audit_backlog_limit=8192 audit=1',
},
services: {
masked: ['nfs-server', 'emacs-service'],
disabled: ['rpcbind', 'autofs', 'nftables'],
enabled: ['crond', 'neovim-service'],
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
});
// TO DO 'Services step' -> 'revisit step button on Review works'
// TO DO 'Services request generated correctly'
// TO DO 'Services edit mode'