From d4e041000eeb261d1e27c84d6a6be5fbb1cd232b Mon Sep 17 00:00:00 2001 From: regexowl Date: Tue, 28 Jan 2025 10:07:46 +0100 Subject: [PATCH] Wizard: Add inputs for disabled and enabled services This adds inputs for disabled and enabled systemd services. New tests are also added. --- .../Services/components/ServicesInputs.tsx | 87 ++++++++ .../steps/Services/index.tsx | 3 + .../CreateImageWizard/validators.ts | 10 + src/store/wizardSlice.ts | 51 +++++ .../steps/Services/Services.test.tsx | 200 +++++++++++++++++- 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx diff --git a/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx b/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx new file mode 100644 index 000000000..8652fa660 --- /dev/null +++ b/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx @@ -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 ( + <> + + + !disabledAndMaskedRequiredByOpenSCAP.includes(service) + )} + requiredList={disabledAndMaskedRequiredByOpenSCAP} + item="Disabled service" + addAction={addDisabledService} + removeAction={removeDisabledService} + /> + + + !enabledRequiredByOpenSCAP.includes(service) + )} + requiredList={enabledRequiredByOpenSCAP} + item="Enabled service" + addAction={addEnabledService} + removeAction={removeEnabledService} + /> + + + ); +}; + +export default ServicesInput; diff --git a/src/Components/CreateImageWizard/steps/Services/index.tsx b/src/Components/CreateImageWizard/steps/Services/index.tsx index 4b3e47674..a0d4cb7c0 100644 --- a/src/Components/CreateImageWizard/steps/Services/index.tsx +++ b/src/Components/CreateImageWizard/steps/Services/index.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Text, Form, Title } from '@patternfly/react-core'; +import ServicesInput from './components/ServicesInputs'; + const ServicesStep = () => { return (
@@ -9,6 +11,7 @@ const ServicesStep = () => { Systemd services Enable and disable systemd services. + ); }; diff --git a/src/Components/CreateImageWizard/validators.ts b/src/Components/CreateImageWizard/validators.ts index 037d070f5..e1465801c 100644 --- a/src/Components/CreateImageWizard/validators.ts +++ b/src/Components/CreateImageWizard/validators.ts @@ -135,3 +135,13 @@ export const isKernelArgumentValid = (arg: string) => { export const isPortValid = (port: string) => { return /^(\d{1,5}|[a-z]{1,6})(-\d{1,5})?:[a-z]{1,6}$/.test(port); }; + +export const isServiceValid = (service: string) => { + // Restraints taken from service name syntax reference + // https://www.rfc-editor.org/rfc/rfc6335#section-5.1 + return ( + service.length <= 15 && + /^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z]$/.test(service) && + !/--/.test(service) + ); +}; diff --git a/src/store/wizardSlice.ts b/src/store/wizardSlice.ts index fee3ecabf..520cc764a 100644 --- a/src/store/wizardSlice.ts +++ b/src/store/wizardSlice.ts @@ -808,12 +808,57 @@ export const wizardSlice = createSlice({ changeEnabledServices: (state, action: PayloadAction) => { state.services.enabled = action.payload; }, + addEnabledService: (state, action: PayloadAction) => { + if ( + !state.services.enabled.some((service) => service === action.payload) + ) { + state.services.enabled.push(action.payload); + } + }, + removeEnabledService: (state, action: PayloadAction) => { + state.services.enabled.splice( + state.services.enabled.findIndex( + (service) => service === action.payload + ), + 1 + ); + }, changeMaskedServices: (state, action: PayloadAction) => { state.services.masked = action.payload; }, + addMaskedService: (state, action: PayloadAction) => { + if ( + !state.services.masked.some((service) => service === action.payload) + ) { + state.services.masked.push(action.payload); + } + }, + removeMaskedService: (state, action: PayloadAction) => { + state.services.masked.splice( + state.services.masked.findIndex( + (service) => service === action.payload + ), + 1 + ); + }, changeDisabledServices: (state, action: PayloadAction) => { state.services.disabled = action.payload; }, + addDisabledService: (state, action: PayloadAction) => { + if ( + !state.services.disabled.some((service) => service === action.payload) + ) { + state.services.disabled.push(action.payload); + } + }, + removeDisabledService: (state, action: PayloadAction) => { + state.services.disabled.splice( + state.services.disabled.findIndex( + (service) => service === action.payload + ), + 1 + ); + }, changeKernelName: (state, action: PayloadAction) => { state.kernel.name = action.payload; }, @@ -973,8 +1018,14 @@ export const { loadWizardState, setFirstBootScript, changeEnabledServices, + addEnabledService, + removeEnabledService, changeMaskedServices, + addMaskedService, + removeMaskedService, changeDisabledServices, + addDisabledService, + removeDisabledService, changeKernelName, addKernelArg, removeKernelArg, diff --git a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx index 6e1461044..4634203b4 100644 --- a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx +++ b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx @@ -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'