diff --git a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx index 496859b27c..bebae176d9 100644 --- a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx +++ b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.jsx @@ -25,11 +25,10 @@ function UpgradablePackagesList({ key: 'patches', render: (content, { to_package_id }) => (
- {content.map(({ advisory_name }) => ( -
- {advisory_name} -
- ))} + {content && + content.map(({ advisory }) => ( +
{advisory}
+ ))}
), }, @@ -47,11 +46,7 @@ function UpgradablePackagesList({ }; }); - return ( -
- - - ); + return
; } export default UpgradablePackagesList; diff --git a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx index 50bd9f3a99..98ca43f778 100644 --- a/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx +++ b/assets/js/common/UpgradablePackagesList/UpgradablePackagesList.test.jsx @@ -16,8 +16,8 @@ describe('UpgradablePackagesList component', () => { expect(screen.getByText(expectedInstalledPackage)).toBeVisible(); expect(screen.getByText(expectedLatestPackage)).toBeVisible(); - patches.forEach(({ advisory_name }) => { - expect(screen.getByText(advisory_name)).toBeVisible(); + patches.forEach(({ advisory }) => { + expect(screen.getByText(advisory)).toBeVisible(); }); }); }); diff --git a/assets/js/common/UpgradablePackagesList/index.js b/assets/js/common/UpgradablePackagesList/index.js new file mode 100644 index 0000000000..b1b71744ac --- /dev/null +++ b/assets/js/common/UpgradablePackagesList/index.js @@ -0,0 +1,3 @@ +import UpgradablePackagesList from './UpgradablePackagesList'; + +export default UpgradablePackagesList; diff --git a/assets/js/lib/api/softwareUpdates.js b/assets/js/lib/api/softwareUpdates.js index f3169ee30a..94562bdc2a 100644 --- a/assets/js/lib/api/softwareUpdates.js +++ b/assets/js/lib/api/softwareUpdates.js @@ -2,3 +2,8 @@ import { networkClient } from '@lib/network'; export const getSoftwareUpdates = (hostID) => networkClient.get(`/hosts/${hostID}/software_updates`); + +export const getPatchesForPackages = (packageIDs) => + networkClient.get(`/software_updates/packages`, { + params: { package_ids: packageIDs }, + }); diff --git a/assets/js/lib/test-utils/factories/relevantPatches.js b/assets/js/lib/test-utils/factories/relevantPatches.js index 21fb31abaf..76148f3940 100644 --- a/assets/js/lib/test-utils/factories/relevantPatches.js +++ b/assets/js/lib/test-utils/factories/relevantPatches.js @@ -12,3 +12,12 @@ export const relevantPatchFactory = Factory.define(() => ({ date: faker.date.anytime(), update_date: faker.date.anytime(), })); + +export const patchForPackageFactory = Factory.define(() => ({ + advisory: faker.animal.cat(), + type: faker.helpers.arrayElement(advisoryType), + synopsis: faker.lorem.sentence(), + issue_date: faker.date.anytime().toString(), + update_date: faker.date.anytime().toString(), + last_modified_date: faker.date.anytime().toString(), +})); diff --git a/assets/js/lib/test-utils/factories/upgradablePackage.js b/assets/js/lib/test-utils/factories/upgradablePackage.js index 1c6b7d215d..bd5273b1c1 100644 --- a/assets/js/lib/test-utils/factories/upgradablePackage.js +++ b/assets/js/lib/test-utils/factories/upgradablePackage.js @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; import { Factory } from 'fishery'; -import { relevantPatchFactory } from './relevantPatches'; +import { patchForPackageFactory } from './relevantPatches'; const releaseVersionFactory = () => `${faker.number.int({ min: 100000, max: 160000 })}.${faker.system.semver()}`; @@ -17,5 +17,5 @@ export const upgradablePackageFactory = Factory.define(() => ({ to_version: faker.system.semver(), from_arch: faker.airline.flightNumber(), to_arch: faker.airline.flightNumber(), - patches: relevantPatchFactory.buildList(2), + patches: patchForPackageFactory.buildList(2), })); diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx new file mode 100644 index 0000000000..2b2983d7b4 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; + +import { getUpgradablePackages } from '@state/selectors/softwareUpdates'; +import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings'; +import { + fetchSoftwareUpdates, + fetchUpgradablePackagesPatches, +} from '@state/softwareUpdates'; + +import BackButton from '@common/BackButton'; +import UpgradablePackagesList from '@common/UpgradablePackagesList'; + +function UpgradablePackagesPage() { + const { hostID } = useParams(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchSoftwareUpdates(hostID)); + dispatch(fetchSoftwareUpdatesSettings()); + }, []); + + const upgradablePackages = useSelector((state) => + getUpgradablePackages(state, hostID) + ); + + useEffect(() => { + const packageIDs = upgradablePackages.map( + ({ to_package_id: packageID }) => packageID + ); + + dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs })); + }, [upgradablePackages.length, hostID]); + + return ( + <> + Back to Host Details + + + ); +} + +export default UpgradablePackagesPage; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx new file mode 100644 index 0000000000..c2818d79c0 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { faker } from '@faker-js/faker'; + +import { + renderWithRouterMatch, + withState, + defaultInitialState, +} from '@lib/test-utils'; +import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; +import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches'; + +import UpgradablePackagesPage from './UpgradablePackagesPage'; + +describe('UpgradablePackagesPage', () => { + it('should render correctly', () => { + const hostID = faker.string.uuid(); + const patch = patchForPackageFactory.build(); + const upgradablePackages = upgradablePackageFactory.buildList(10, { + patches: [patch], + }); + const [{ name }] = upgradablePackages; + + const [StatefulPage] = withState(, { + ...defaultInitialState, + softwareUpdates: { + softwareUpdates: { + [hostID]: { + loading: false, + errors: [], + upgradable_packages: upgradablePackages, + }, + }, + }, + }); + + renderWithRouterMatch(StatefulPage, { + path: 'hosts/:hostID/packages', + route: `/hosts/${hostID}/packages`, + }); + + expect(screen.getAllByText(name, { exact: false })).toHaveLength(2); + }); +}); diff --git a/assets/js/pages/UpgradablePackagesPage/index.js b/assets/js/pages/UpgradablePackagesPage/index.js new file mode 100644 index 0000000000..3fde2855a7 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/index.js @@ -0,0 +1,3 @@ +import UpgradablePackagesPage from './UpgradablePackagesPage'; + +export default UpgradablePackagesPage; diff --git a/assets/js/state/sagas/softwareUpdates.js b/assets/js/state/sagas/softwareUpdates.js index 0a5c1e895b..8e4cbf695f 100644 --- a/assets/js/state/sagas/softwareUpdates.js +++ b/assets/js/state/sagas/softwareUpdates.js @@ -1,13 +1,18 @@ import { get } from 'lodash'; import { put, call, takeEvery } from 'redux-saga/effects'; -import { getSoftwareUpdates } from '@lib/api/softwareUpdates'; +import { + getSoftwareUpdates, + getPatchesForPackages, +} from '@lib/api/softwareUpdates'; import { + FETCH_UPGRADABLE_PACKAGES_PATCHES, FETCH_SOFTWARE_UPDATES, startLoadingSoftwareUpdates, setSoftwareUpdates, setEmptySoftwareUpdates, setSoftwareUpdatesErrors, + setPatchesForPackages, } from '@state/softwareUpdates'; export function* fetchSoftwareUpdates({ payload: hostID }) { @@ -24,6 +29,23 @@ export function* fetchSoftwareUpdates({ payload: hostID }) { } } +export function* fetchUpgradablePackagesPatches({ + payload: { hostID, packageIDs }, +}) { + try { + const { + data: { patches }, + } = yield call(getPatchesForPackages, packageIDs); + yield put(setPatchesForPackages({ hostID, patches })); + } catch (error) { + yield put(setPatchesForPackages({ hostID, patches: [] })); + } +} + export function* watchSoftwareUpdates() { yield takeEvery(FETCH_SOFTWARE_UPDATES, fetchSoftwareUpdates); + yield takeEvery( + FETCH_UPGRADABLE_PACKAGES_PATCHES, + fetchUpgradablePackagesPatches + ); } diff --git a/assets/js/state/sagas/softwareUpdates.test.js b/assets/js/state/sagas/softwareUpdates.test.js index ec2ecbb7dc..8304df1e4d 100644 --- a/assets/js/state/sagas/softwareUpdates.test.js +++ b/assets/js/state/sagas/softwareUpdates.test.js @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker'; import MockAdapter from 'axios-mock-adapter'; import { recordSaga } from '@lib/test-utils'; +import { patchForPackageFactory } from '@lib/test-utils/factories'; import { networkClient } from '@lib/network'; @@ -10,9 +11,13 @@ import { setSoftwareUpdates, setSoftwareUpdatesErrors, setEmptySoftwareUpdates, + setPatchesForPackages, } from '@state/softwareUpdates'; -import { fetchSoftwareUpdates } from './softwareUpdates'; +import { + fetchSoftwareUpdates, + fetchUpgradablePackagesPatches, +} from './softwareUpdates'; describe('Software Updates saga', () => { describe('Fetching Software Updates', () => { @@ -87,4 +92,29 @@ describe('Software Updates saga', () => { } ); }); + + describe('Fetching patches for packages', () => { + it('sets patches for upgradable packages', async () => { + const axiosMock = new MockAdapter(networkClient); + const hostID = faker.string.uuid(); + const packageIDs = [faker.string.uuid(), faker.string.uuid()]; + const patches = patchForPackageFactory.buildList(3); + const response = { + patches: [ + { package_id: packageIDs[0], patches }, + { package_id: packageIDs[1], patches }, + ], + }; + + axiosMock.onGet(`/api/v1/software_updates/packages`).reply(200, response); + + const dispatched = await recordSaga(fetchUpgradablePackagesPatches, { + payload: { hostID, packageIDs }, + }); + + expect(dispatched).toEqual([ + setPatchesForPackages({ hostID, patches: response.patches }), + ]); + }); + }); }); diff --git a/assets/js/state/selectors/softwareUpdates.js b/assets/js/state/selectors/softwareUpdates.js index 991a700a8c..a565aec2b3 100644 --- a/assets/js/state/selectors/softwareUpdates.js +++ b/assets/js/state/selectors/softwareUpdates.js @@ -22,6 +22,11 @@ export const getSoftwareUpdatesPatches = createSelector( (softwareUpdates) => get(softwareUpdates, ['relevant_patches'], []) ); +export const getUpgradablePackages = createSelector( + [(state, id) => getSoftwareUpdatesForHost(id)(state)], + (softwareUpdates) => get(softwareUpdates, ['upgradable_packages'], []) +); + export const getSoftwareUpdatesLoading = createSelector( [(state, id) => getSoftwareUpdatesForHost(id)(state)], (softwareUpdates) => get(softwareUpdates, ['loading'], false) diff --git a/assets/js/state/selectors/softwareUpdates.test.js b/assets/js/state/selectors/softwareUpdates.test.js index a8c012de5e..8432d329b5 100644 --- a/assets/js/state/selectors/softwareUpdates.test.js +++ b/assets/js/state/selectors/softwareUpdates.test.js @@ -4,6 +4,7 @@ import { getSoftwareUpdatesStats, getSoftwareUpdatesLoading, getSoftwareUpdatesPatches, + getUpgradablePackages, } from './softwareUpdates'; describe('Software Updates selector', () => { @@ -134,4 +135,10 @@ describe('Software Updates selector', () => { softwareUpdates[hostID].relevant_patches ); }); + + it('should return the upgradable packages', () => { + expect(getUpgradablePackages(state, hostID)).toEqual( + softwareUpdates[hostID].upgradable_packages + ); + }); }); diff --git a/assets/js/state/softwareUpdates.js b/assets/js/state/softwareUpdates.js index 53c0475a27..2653705f66 100644 --- a/assets/js/state/softwareUpdates.js +++ b/assets/js/state/softwareUpdates.js @@ -1,4 +1,5 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; +import { find, get, isEmpty } from 'lodash'; const initialState = { softwareUpdates: {}, @@ -44,18 +45,47 @@ export const softwareUpdatesSlice = createSlice({ [hostID]: { ...initialHostState, errors }, }; }, + setPatchesForPackages: (state, { payload: { hostID, patches } }) => { + const packages = get( + state, + ['softwareUpdates', hostID, 'upgradable_packages'], + [] + ); + + const newPackages = packages.map((currentPackage) => { + const { to_package_id: packageID } = currentPackage; + + const packageInfo = find(patches, { package_id: packageID }); + const packagePatches = get(packageInfo, 'patches', []); + + return { + ...currentPackage, + patches: packagePatches, + }; + }); + + if (!isEmpty(packages)) { + state.softwareUpdates[hostID].upgradable_packages = newPackages; + } + }, }, }); export const FETCH_SOFTWARE_UPDATES = 'FETCH_SOFTWARE_UPDATES'; +export const FETCH_UPGRADABLE_PACKAGES_PATCHES = + 'FETCH_UPGRADABLE_PACKAGES_PATCHES'; export const fetchSoftwareUpdates = createAction(FETCH_SOFTWARE_UPDATES); +export const fetchUpgradablePackagesPatches = createAction( + FETCH_UPGRADABLE_PACKAGES_PATCHES +); export const { startLoadingSoftwareUpdates, setSoftwareUpdates, setEmptySoftwareUpdates, setSoftwareUpdatesErrors, + setPatchesForPackages, } = softwareUpdatesSlice.actions; export default softwareUpdatesSlice.reducer; diff --git a/assets/js/state/softwareUpdates.test.js b/assets/js/state/softwareUpdates.test.js index 4c59c539fd..64cf3b6043 100644 --- a/assets/js/state/softwareUpdates.test.js +++ b/assets/js/state/softwareUpdates.test.js @@ -1,10 +1,13 @@ import { faker } from '@faker-js/faker'; +import { patchForPackageFactory } from '@lib/test-utils/factories'; + import softwareUpdatesReducer, { startLoadingSoftwareUpdates, setSoftwareUpdates, setEmptySoftwareUpdates, setSoftwareUpdatesErrors, + setPatchesForPackages, } from './softwareUpdates'; describe('SoftwareUpdates reducer', () => { @@ -231,4 +234,78 @@ describe('SoftwareUpdates reducer', () => { }, }); }); + + it('should set patches that cover a specific package upgrade', () => { + const host1 = faker.string.uuid(); + + const initialState = { + softwareUpdates: { + [host1]: { + loading: false, + errors: [], + relevant_patches: [ + { + date: '2024-03-11', + advisory_name: 'SUSE-15-SP4-2024-833', + advisory_type: 'security_advisory', + advisory_status: 'stable', + id: 4244, + advisory_synopsis: 'moderate: Security update for openssl-1_1', + update_date: '2024-03-11', + }, + ], + upgradable_packages: [ + { + from_epoch: ' ', + to_release: '150100.8.33.1', + name: 'saptune', + from_release: '150400.3.208.1', + to_epoch: ' ', + arch: 'x86_64', + to_package_id: 39942, + from_version: '3.1.0', + to_version: '3.1.2', + from_arch: 'x86_64', + to_arch: 'x86_64', + }, + ], + }, + }, + }; + + const patch = patchForPackageFactory.build(); + const action = setPatchesForPackages({ + hostID: host1, + patches: [{ package_id: 39942, patches: [patch] }], + }); + + const { + softwareUpdates: { + [host1]: { + upgradable_packages: [{ patches }], + }, + }, + } = softwareUpdatesReducer(initialState, action); + + expect(patches).toHaveLength(1); + expect(patches[0]).toEqual(patch); + }); + + it('should not apply any patch if the host does not exist', () => { + const host1 = faker.string.uuid(); + + const initialState = { + softwareUpdates: {}, + }; + + const patch = patchForPackageFactory.build(); + const action = setPatchesForPackages({ + hostID: host1, + patches: [{ package_id: 39942, patches: [patch] }], + }); + + const { softwareUpdates } = softwareUpdatesReducer(initialState, action); + + expect(softwareUpdates).toEqual({}); + }); }); diff --git a/assets/js/trento.jsx b/assets/js/trento.jsx index 6fbdcd4a66..fa1be7c400 100644 --- a/assets/js/trento.jsx +++ b/assets/js/trento.jsx @@ -22,6 +22,7 @@ import Home from '@pages/Home'; import HostDetailsPage from '@pages/HostDetailsPage'; import HostSettingsPage from '@pages/HostSettingsPage'; import HostRelevanPatchesPage from '@pages/HostRelevantPatches'; +import UpgradablePackagesPage from '@pages/UpgradablePackagesPage'; import HostsList from '@pages/HostsList'; import Layout from '@pages/Layout'; import Login from '@pages/Login'; @@ -74,6 +75,10 @@ function App() { path="hosts/:hostID/patches" element={} /> + } + /> } />