diff --git a/cypress/e2e/vulnerability.cy.js b/cypress/e2e/vulnerability.cy.js new file mode 100644 index 0000000..ace87d1 --- /dev/null +++ b/cypress/e2e/vulnerability.cy.js @@ -0,0 +1,373 @@ +import { securityPage, KNOWN_VULNERABLE_IMAGE } from '../views/securityPage' +import { devicesPage } from '../views/devicesPage' +import { common } from '../views/common' + +/** + * Vulnerability Reporting End-to-End Tests + * + * Prerequisites: + * - Jenkins job must run with flightctl_trustify_enabled: true + * - Trustify must have SBOM data for the vulnerable test image + * - At least one device must be enrolled and available for testing + * + * Test Image Details: + * - Image: quay.io/centos-bootc/centos-bootc:stream9 + * - Digest: sha256:0395113f05c2fe5544ddb693603abaaee36f71d46c810526efd270327db6b8bd + * - CVEs: 7 total (1 Critical, 1 High, 4 Medium, 1 Low) + */ + +describe('Vulnerability Reporting', () => { + const FLEET_NAME = 'test-fleet-vuln-' + Date.now() + let TEST_DEVICE_NAME = null + + const API = Cypress.env('FLIGHTCTL_API') + + before(() => { + cy.ensureLoggedIn() + + // Find an enrolled device to use for testing + cy.request(`${API}/api/v1/devices`).then((resp) => { + const devices = resp.body.items || [] + const enrolledDevices = devices.filter((d) => d.metadata.labels?.enrollment !== 'pending') + + if (enrolledDevices.length === 0) { + throw new Error('No enrolled devices available for vulnerability testing') + } + + TEST_DEVICE_NAME = enrolledDevices[0].metadata.name + cy.log(`Using device: ${TEST_DEVICE_NAME}`) + }) + }) + + after(() => { + // Cleanup: Delete test fleet if it exists + if (FLEET_NAME) { + cy.request({ + method: 'DELETE', + url: `${API}/api/v1/fleets/${FLEET_NAME}`, + failOnStatusCode: false, + }) + } + + // Cleanup: Remove fleet label from device if it was added + if (TEST_DEVICE_NAME) { + cy.request({ + method: 'PATCH', + url: `${API}/api/v1/devices/${TEST_DEVICE_NAME}`, + body: { + metadata: { + labels: { + fleet: null, + }, + }, + }, + failOnStatusCode: false, + }) + } + }) + + /** + * Helper: Wait for device status to be UpToDate + */ + const waitForDeviceUpToDate = (deviceName, timeout = 300000) => { + cy.log(`Waiting for device ${deviceName} to reach UpToDate status...`) + cy.waitUntil( + () => + cy.request(`${API}/api/v1/devices/${deviceName}`).then((resp) => { + const status = resp.body?.status?.updated?.status + cy.log(`Device status: ${status}`) + return status === 'UpToDate' + }), + { + timeout, + interval: 5000, + errorMsg: `Device ${deviceName} did not reach UpToDate status within ${timeout}ms`, + }, + ) + } + + /** + * Helper: Wait for device to report specific digest + */ + const waitForDeviceDigest = (deviceName, expectedDigest, timeout = 300000) => { + cy.log(`Waiting for device ${deviceName} to update to digest ${expectedDigest}...`) + cy.waitUntil( + () => + cy.request(`${API}/api/v1/devices/${deviceName}`).then((resp) => { + const currentDigest = resp.body?.status?.os?.imageDigest + cy.log(`Current digest: ${currentDigest}`) + return currentDigest === expectedDigest + }), + { + timeout, + interval: 5000, + errorMsg: `Device did not update to digest ${expectedDigest} within ${timeout}ms`, + }, + ) + // Wait extra 15s for vulnerability sync (runs every 10s) + cy.log('Waiting additional 15s for vulnerability sync...') + cy.wait(15000) + } + + describe('Initial State - No Vulnerabilities', () => { + it('should show 0 vulnerabilities on Overview page', () => { + cy.visit('/overview') + securityPage.expectSecurityOverviewVisible() + securityPage.expectNoVulnerabilities() + }) + + it('should show 0 vulnerabilities on Device page', () => { + cy.visit(`/devices/${TEST_DEVICE_NAME}`) + cy.wait(1000) // Allow page to load + + // Check if Security overview section exists and shows no vulnerabilities + cy.get('body').then(($body) => { + if ($body.text().includes('Security overview')) { + securityPage.expectNoVulnerabilities() + } else { + cy.log('Security overview section not present yet') + } + }) + }) + }) + + describe('Create Fleet with Vulnerable Image', () => { + it('should create a fleet with the vulnerable CentOS image', () => { + cy.request('POST', `${API}/api/v1/fleets`, { + apiVersion: 'v1alpha1', + kind: 'Fleet', + metadata: { + name: FLEET_NAME, + }, + spec: { + template: { + spec: { + os: { + image: KNOWN_VULNERABLE_IMAGE.image, + }, + }, + }, + }, + }).then((response) => { + expect(response.status).to.eq(201) + cy.log(`Fleet ${FLEET_NAME} created successfully`) + }) + }) + + it('should verify fleet shows 0 CVEs before device attachment', () => { + cy.visit(`/fleets/${FLEET_NAME}`) + cy.wait(1000) + + // Fleet has vulnerable image spec, but no devices running it yet + cy.get('body').then(($body) => { + if ($body.text().includes('Security overview')) { + securityPage.expectNoVulnerabilities() + } else { + cy.log('Security overview section not present yet on fleet') + } + }) + }) + }) + + describe('Attach Device to Fleet and Wait for CVEs', () => { + it('should attach device to vulnerable fleet', () => { + cy.request('PATCH', `${API}/api/v1/devices/${TEST_DEVICE_NAME}`, { + metadata: { + labels: { + fleet: FLEET_NAME, + }, + }, + }).then((response) => { + expect(response.status).to.eq(200) + cy.log(`Device ${TEST_DEVICE_NAME} attached to fleet ${FLEET_NAME}`) + }) + }) + + it('should wait for device to pull vulnerable image and reach UpToDate', () => { + // Wait for device to pull the new image + waitForDeviceDigest(TEST_DEVICE_NAME, KNOWN_VULNERABLE_IMAGE.digest) + + // Wait for device status to be UpToDate + waitForDeviceUpToDate(TEST_DEVICE_NAME) + + cy.log('Device is now running the vulnerable image and is UpToDate') + }) + }) + + describe('Verify CVEs on Device Page', () => { + beforeEach(() => { + cy.visit(`/devices/${TEST_DEVICE_NAME}`) + cy.wait(2000) // Allow vulnerability data to load + }) + + it('should display total vulnerability count', () => { + securityPage.expectVulnerabilityCount(KNOWN_VULNERABLE_IMAGE.totalCount) + }) + + it('should display severity breakdown', () => { + securityPage.expectSeverityCounts(KNOWN_VULNERABLE_IMAGE.counts) + }) + + it('should display Critical CVEs', () => { + securityPage.expectCvesVisible(KNOWN_VULNERABLE_IMAGE.cves.critical) + }) + + it('should display High CVEs', () => { + securityPage.expectCvesVisible(KNOWN_VULNERABLE_IMAGE.cves.high) + }) + + it('should open CVE details panel when clicking on a CVE', () => { + const criticalCve = KNOWN_VULNERABLE_IMAGE.cves.critical[0] + + securityPage.clickCve(criticalCve) + securityPage.expectCveDetailsVisible() + securityPage.expectSeverityInDetails('Critical') + securityPage.expectPackageInDetails('glibc') + securityPage.closeCveDetails() + }) + + it('should filter vulnerabilities by Critical severity', () => { + securityPage.filterBySeverity('Critical') + securityPage.expectTableRowCount(KNOWN_VULNERABLE_IMAGE.counts.critical) + securityPage.expectCveVisible(KNOWN_VULNERABLE_IMAGE.cves.critical[0]) + securityPage.clearFilters() + }) + + it('should filter vulnerabilities by High severity', () => { + securityPage.filterBySeverity('High') + securityPage.expectTableRowCount(KNOWN_VULNERABLE_IMAGE.counts.high) + securityPage.expectCveVisible(KNOWN_VULNERABLE_IMAGE.cves.high[0]) + securityPage.clearFilters() + }) + + it('should search for specific CVE by ID', () => { + const searchCve = KNOWN_VULNERABLE_IMAGE.cves.critical[0] + securityPage.searchVulnerabilities(searchCve) + securityPage.expectCveVisible(searchCve) + securityPage.clearSearch() + }) + }) + + describe('Verify CVEs on Fleet Page', () => { + beforeEach(() => { + cy.visit(`/fleets/${FLEET_NAME}`) + cy.wait(2000) + }) + + it('should display total vulnerability count on fleet', () => { + securityPage.expectVulnerabilityCount(KNOWN_VULNERABLE_IMAGE.totalCount) + }) + + it('should display severity breakdown on fleet', () => { + securityPage.expectSeverityCounts(KNOWN_VULNERABLE_IMAGE.counts) + }) + + it('should display CVEs on fleet page', () => { + securityPage.expectCveVisible(KNOWN_VULNERABLE_IMAGE.cves.critical[0]) + securityPage.expectCveVisible(KNOWN_VULNERABLE_IMAGE.cves.high[0]) + }) + + it('should open CVE details from fleet page', () => { + const highCve = KNOWN_VULNERABLE_IMAGE.cves.high[0] + + securityPage.clickCve(highCve) + securityPage.expectCveDetailsVisible() + securityPage.expectSeverityInDetails('High') + securityPage.closeCveDetails() + }) + + it('should filter vulnerabilities on fleet page', () => { + securityPage.filterBySeverity('High') + securityPage.expectCveVisible(KNOWN_VULNERABLE_IMAGE.cves.high[0]) + securityPage.clearFilters() + }) + }) + + describe('Verify CVEs on Overview Page', () => { + beforeEach(() => { + cy.visit('/overview') + cy.wait(2000) + }) + + it('should display aggregated vulnerabilities on overview', () => { + securityPage.expectVulnerabilityCount(KNOWN_VULNERABLE_IMAGE.totalCount) + }) + + it('should display severity breakdown on overview', () => { + securityPage.expectSeverityCounts(KNOWN_VULNERABLE_IMAGE.counts) + }) + }) + + describe('Detach Device and Verify CVEs Disappear', () => { + it('should detach device from fleet', () => { + cy.request('PATCH', `${API}/api/v1/devices/${TEST_DEVICE_NAME}`, { + metadata: { + labels: { + fleet: null, + }, + }, + }).then((response) => { + expect(response.status).to.eq(200) + cy.log(`Device ${TEST_DEVICE_NAME} detached from fleet ${FLEET_NAME}`) + }) + }) + + it('should wait for device to rollback from vulnerable image', () => { + cy.log('Waiting for device to rollback to original image...') + cy.waitUntil( + () => + cy.request(`${API}/api/v1/devices/${TEST_DEVICE_NAME}`).then((resp) => { + const currentDigest = resp.body?.status?.os?.imageDigest + const hasRolledBack = currentDigest !== KNOWN_VULNERABLE_IMAGE.digest + if (hasRolledBack) { + cy.log(`Device rolled back to: ${currentDigest}`) + } + return hasRolledBack + }), + { + timeout: 300000, + interval: 5000, + errorMsg: 'Device did not rollback from vulnerable image', + }, + ) + + // Wait for device to be UpToDate again + waitForDeviceUpToDate(TEST_DEVICE_NAME) + + // Wait for vulnerability sync to clear + cy.log('Waiting for vulnerability sync to clear...') + cy.wait(15000) + }) + + it('should verify CVEs disappeared from Device page', () => { + cy.visit(`/devices/${TEST_DEVICE_NAME}`) + cy.wait(2000) + + securityPage.expectNoVulnerabilities() + }) + + it('should verify CVEs disappeared from Fleet page', () => { + cy.visit(`/fleets/${FLEET_NAME}`) + cy.wait(2000) + + // Fleet has no devices attached now, should show 0 CVEs + securityPage.expectNoVulnerabilities() + }) + + it('should verify CVEs disappeared from Overview page', () => { + cy.visit('/overview') + cy.wait(2000) + + securityPage.expectNoVulnerabilities() + }) + }) + + describe('Cleanup', () => { + it('should delete test fleet', () => { + cy.request('DELETE', `${API}/api/v1/fleets/${FLEET_NAME}`).then((response) => { + expect(response.status).to.eq(200) + cy.log(`Fleet ${FLEET_NAME} deleted successfully`) + }) + }) + }) +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index cbecd07..45a4845 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,4 +1,5 @@ -require('cypress-downloadfile/lib/downloadFileCommand') +import 'cypress-downloadfile/lib/downloadFileCommand' +import { common } from '../views/common' /** * OpenShift console "Welcome to the new OpenShift experience" / guided tour intro — no stable @@ -98,19 +99,44 @@ Cypress.Commands.add('ensureLoggedIn', () => { const auth = Cypress.env('auth') const user = Cypress.env('username') const password = Cypress.env('password') + + cy.log('=== ensureLoggedIn: Starting ===') + cy.session( ['openshift-console', host, auth, user], () => { + cy.log('Session setup callback: performing login') cy.login(host, auth, user, password) + }, + { + validate() { + cy.log('Session validation: checking localStorage') + cy.window().then((win) => { + const org = win.localStorage.getItem('flightctl-current-organization') + cy.log(`Session validation - Org in localStorage: ${org || '(none)'}`) + }) + } } ) + + cy.log('Session restored, visiting host') cy.visit(host, { timeout: 60000, retryOnStatusCodeFailure: true }) + + cy.log('Closing welcome/onboarding modals') tryCloseConsoleWelcomeTourModal() tryCloseOnboardingModal() + if (Cypress.env('useAcmNavigation')) { + cy.log('Using ACM navigation - selecting Fleet management perspective') cy.selectFleetManagementPerspective() } + + // NOTE: Do NOT call selectOrganizationIfNeeded() here! + // It will be called by the first navigateTo() which ensures it happens at the right time + // Calling it here can mark org selection as "handled" before the modal actually appears + cy.url().should('include', host) + cy.log('=== ensureLoggedIn: Complete ===') }) Cypress.Commands.add('downloadClifile', (platform = `${Cypress.env('platform')}`, arch = `${Cypress.env('arch')}`) => { diff --git a/cypress/views/devicesPage.js b/cypress/views/devicesPage.js index ac1bd64..e3f203e 100644 --- a/cypress/views/devicesPage.js +++ b/cypress/views/devicesPage.js @@ -540,4 +540,14 @@ export const devicesPage = { cy.contains('button', 'Add label').click() cy.get('input[aria-label="New label"]').clear().type(`${labelText}{enter}`) }, + + /** + * Navigate to device details and scroll to Security overview section + * @param {string} deviceName - Device name or reference + */ + viewDeviceSecurity: (deviceName) => { + cy.visit(`/devices/${deviceName}`) + cy.wait(1000) + cy.contains('Security overview').should('be.visible').scrollIntoView() + }, } diff --git a/cypress/views/fleetsPage.js b/cypress/views/fleetsPage.js index 86f445a..601257f 100644 --- a/cypress/views/fleetsPage.js +++ b/cypress/views/fleetsPage.js @@ -144,4 +144,14 @@ export const fleetsPage = { cy.get('[data-testid="wizard-save-button"]').click() cy.get('td[data-label="Status"]', { timeout: 1000000 }).should('contain', 'Valid') }, + + /** + * Navigate to fleet details and scroll to Security overview section + * @param {string} fleetName - Fleet name + */ + viewFleetSecurity: (fleetName) => { + cy.visit(`/fleets/${fleetName}`) + cy.wait(1000) + cy.contains('Security overview').should('be.visible').scrollIntoView() + }, } diff --git a/cypress/views/securityPage.js b/cypress/views/securityPage.js new file mode 100644 index 0000000..bbc1451 --- /dev/null +++ b/cypress/views/securityPage.js @@ -0,0 +1,257 @@ +import { common } from './common' + +/** + * Security/Vulnerability UI selectors and helpers. + * Used across Device, Fleet, and Overview pages. + */ + +/** Security overview card title */ +const SECURITY_OVERVIEW_CARD = '.pf-v6-c-card__title-text:contains("Security overview")' + +/** Filter by severity dropdown button */ +const SEVERITY_FILTER_TOGGLE = 'button[aria-label="Filter by severity"]' + +/** Search/find vulnerabilities by name input */ +const VULNERABILITY_SEARCH_INPUT = 'input[aria-label="Find by name"]' + +/** Vulnerabilities table */ +const VULNERABILITIES_TABLE = 'table[aria-label="Vulnerabilities table"]' + +/** Table rows containing CVE data */ +const VULNERABILITY_ROWS = `${VULNERABILITIES_TABLE} tbody tr` + +/** Empty state when no vulnerabilities found */ +const NO_VULNERABILITIES_EMPTY_STATE = '.pf-v6-c-empty-state' + +/** CVE details panel/drawer (when clicking on a CVE) */ +const CVE_DETAILS_PANEL = '[role="dialog"], .pf-v6-c-drawer__panel' + +/** Close button for CVE details */ +const CLOSE_DETAILS_BUTTON = 'button[aria-label="Close drawer panel"]' + +/** Severity badge/label colors (PatternFly label components) */ +const SEVERITY_LABELS = { + critical: '.pf-m-red', + high: '.pf-m-orange', + medium: '.pf-m-gold', + low: '.pf-m-blue', +} + +/** + * Vulnerability severity levels with their expected display text and filter options + */ +export const SEVERITY_LEVELS = { + CRITICAL: { name: 'Critical', color: 'red', filterText: 'Critical' }, + HIGH: { name: 'High', color: 'orange', filterText: 'High' }, + MEDIUM: { name: 'Medium', color: 'gold', filterText: 'Medium' }, + LOW: { name: 'Low', color: 'blue', filterText: 'Low' }, +} + +/** + * Expected CVEs for the test image: quay.io/centos-bootc/centos-bootc:stream9 + * Digest: sha256:0395113f05c2fe5544ddb693603abaaee36f71d46c810526efd270327db6b8bd + */ +export const KNOWN_VULNERABLE_IMAGE = { + image: 'quay.io/centos-bootc/centos-bootc:stream9', + digest: 'sha256:0395113f05c2fe5544ddb693603abaaee36f71d46c810526efd270327db6b8bd', + cves: { + critical: ['CVE-2021-35942'], + high: ['CVE-2023-4911'], + medium: ['CVE-2022-3715', 'CVE-2024-2961', 'CVE-2024-33599', 'CVE-2024-33600'], + low: ['CVE-2024-33601'], + }, + totalCount: 7, + counts: { + critical: 1, + high: 1, + medium: 4, + low: 1, + }, +} + +export const securityPage = { + /** + * Verify the security overview card is visible + */ + expectSecurityOverviewVisible() { + cy.contains('Security overview').should('be.visible') + }, + + /** + * Verify vulnerability count is displayed + * @param {number} count - Expected total number of vulnerabilities + */ + expectVulnerabilityCount(count) { + if (count === 0) { + // Check for various possible empty state texts + cy.get('body').then(($body) => { + const hasEmptyState = + $body.text().includes('No vulnerabilities found') || + $body.text().includes('0 vulnerabilities') || + $body.find(NO_VULNERABILITIES_EMPTY_STATE).length > 0 + expect(hasEmptyState).to.be.true + }) + } else { + const countText = count === 1 ? '1 vulnerability' : `${count} vulnerabilities` + cy.contains(countText, { timeout: 10000 }).should('be.visible') + } + }, + + /** + * Verify severity breakdown counts + * @param {object} counts - Object with critical, high, medium, low counts + */ + expectSeverityCounts(counts) { + if (counts.critical > 0) { + cy.contains(`${counts.critical} Critical`).should('be.visible') + } + if (counts.high > 0) { + cy.contains(`${counts.high} High`).should('be.visible') + } + if (counts.medium > 0) { + cy.contains(`${counts.medium} Medium`).should('be.visible') + } + if (counts.low > 0) { + cy.contains(`${counts.low} Low`).should('be.visible') + } + }, + + /** + * Verify specific CVE is listed in the table + * @param {string} cveId - CVE identifier (e.g., 'CVE-2021-35942') + */ + expectCveVisible(cveId) { + cy.contains(VULNERABILITY_ROWS, cveId, { timeout: 10000 }).should('be.visible') + }, + + /** + * Verify multiple CVEs are visible + * @param {string[]} cveIds - Array of CVE identifiers + */ + expectCvesVisible(cveIds) { + cveIds.forEach((cveId) => { + this.expectCveVisible(cveId) + }) + }, + + /** + * Click on a specific CVE to open details panel + * @param {string} cveId - CVE identifier + */ + clickCve(cveId) { + cy.contains(VULNERABILITY_ROWS, cveId).click() + }, + + /** + * Verify CVE details panel is visible + */ + expectCveDetailsVisible() { + cy.get(CVE_DETAILS_PANEL, { timeout: 5000 }).should('be.visible') + }, + + /** + * Close the CVE details panel + */ + closeCveDetails() { + cy.get(CLOSE_DETAILS_BUTTON).click() + cy.get(CVE_DETAILS_PANEL).should('not.exist') + }, + + /** + * Verify severity label appears in details panel + * @param {string} severity - Severity level ('Critical', 'High', 'Medium', 'Low') + */ + expectSeverityInDetails(severity) { + cy.get(CVE_DETAILS_PANEL).contains(severity).should('be.visible') + }, + + /** + * Verify package name appears in CVE details + * @param {string} packageName - Package name (e.g., 'glibc') + */ + expectPackageInDetails(packageName) { + cy.get(CVE_DETAILS_PANEL).contains(packageName).should('be.visible') + }, + + /** + * Filter vulnerabilities by severity + * @param {string} severity - Severity to filter by ('Critical', 'High', 'Medium', 'Low') + */ + filterBySeverity(severity) { + cy.get(SEVERITY_FILTER_TOGGLE).click() + cy.contains('li', severity).click() + // Click outside to close menu + cy.get('body').click(0, 0) + }, + + /** + * Clear all filters + */ + clearFilters() { + // Look for clear filters button or chip group clear + cy.get('body').then(($body) => { + if ($body.find('button:contains("Clear all filters")').length > 0) { + cy.contains('button', 'Clear all filters').click() + } else if ($body.find('.pf-v6-c-chip-group__close button').length > 0) { + cy.get('.pf-v6-c-chip-group__close button').click() + } + }) + }, + + /** + * Search for a CVE by ID or text + * @param {string} searchText - Text to search for + */ + searchVulnerabilities(searchText) { + cy.get(VULNERABILITY_SEARCH_INPUT).clear().type(searchText) + }, + + /** + * Clear the search input + */ + clearSearch() { + cy.get(VULNERABILITY_SEARCH_INPUT).clear() + }, + + /** + * Get the count of visible vulnerability rows in the table + * @returns {Cypress.Chainable} + */ + getVisibleVulnerabilityCount() { + return cy.get(VULNERABILITY_ROWS).its('length') + }, + + /** + * Verify the table shows exactly N vulnerabilities + * @param {number} expectedCount + */ + expectTableRowCount(expectedCount) { + if (expectedCount === 0) { + cy.get(VULNERABILITY_ROWS).should('not.exist') + } else { + cy.get(VULNERABILITY_ROWS).should('have.length', expectedCount) + } + }, + + /** + * Verify no vulnerabilities are shown (empty state) + */ + expectNoVulnerabilities() { + this.expectVulnerabilityCount(0) + }, + + /** + * Wait for vulnerability data to be loaded and displayed + * @param {number} timeout - Timeout in ms (default 15000) + */ + waitForVulnerabilityDataLoad(timeout = 15000) { + cy.get('body', { timeout }).should(($body) => { + const text = $body.text() + const hasData = + text.includes('vulnerabilit') || + text.includes('CVE-') || + text.includes('No vulnerabilities found') + expect(hasData).to.be.true + }) + }, +}