From 642efecd377cf22f7cfb781314cfa45c6351517f Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Tue, 21 Oct 2025 13:10:43 +0200 Subject: [PATCH] test(ui): refactoring tests Correct serial execution Best process for deleting a provider New scans page to manage properly when creating a provider --- .github/workflows/ui-e2e-tests.yml | 11 +- ui/tests/helpers.ts | 11 +- ui/tests/providers/providers-page.ts | 236 +++++++++++++++++---------- ui/tests/providers/providers.spec.ts | 81 +++++---- ui/tests/scans/scans-page.ts | 28 ++++ 5 files changed, 240 insertions(+), 127 deletions(-) create mode 100644 ui/tests/scans/scans-page.ts diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 19ab814456..be730fe59f 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -1,6 +1,9 @@ name: UI - E2E Tests on: + push: + branches: + - "PROWLER-182-kubernetes-add-and-connect-the-provider" pull_request: branches: - master @@ -10,6 +13,7 @@ on: - 'ui/**' jobs: + e2e-tests: if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest @@ -33,9 +37,10 @@ jobs: E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} - E2E_KUBERNETES_CONTEXT: ${{ secrets.E2E_KUBERNETES_CONTEXT }} - E2E_KUBERNETES_KUBECONFIG_PATH: ${{ secrets.E2E_KUBERNETES_KUBECONFIG_PATH }} + E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_KUBECONFIG_PATH: '/home/runner/.kube/config' E2E_NEW_PASSWORD: ${{ secrets.E2E_NEW_PASSWORD }} + steps: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -115,4 +120,4 @@ jobs: run: | echo "Shutting down services..." docker compose down -v || true - echo "Cleanup completed" + echo "Cleanup completed" \ No newline at end of file diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index fbe151d17d..efa87693d8 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -147,7 +147,9 @@ export async function authenticateAndSaveState( storagePath: string, ) { if (!email || !password) { - throw new Error('Email and password are required for authentication and save state'); + throw new Error( + "Email and password are required for authentication and save state", + ); } // Create SignInPage instance @@ -188,10 +190,3 @@ export async function verifySessionValid(page: Page) { expect(session.refreshToken).toBeTruthy(); return session; } - -export async function deleteProviderIfExists(page: Page, accountId: string) { - const providersPage = new ProvidersPage(page); - if (await providersPage.verifyProviderExists(accountId)) { - await providersPage.actionDeleteProvider(accountId); - } -} diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 59a8617edb..c2edc25304 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -239,10 +239,11 @@ export class ProvidersPage extends BasePage { this.accessKeyIdInput = page.getByRole("textbox", { name: "Access Key ID" }); this.secretAccessKeyInput = page.getByRole("textbox", { name: "Secret Access Key" }); - // Delete button - this.deleteProviderConfirmationButton = page.locator( - 'button[aria-label="Delete"]', - ); + // Delete button in confirmation modal + this.deleteProviderConfirmationButton = page.getByRole("button", { + name: "Delete", + exact: true, + }); } async goto(): Promise { @@ -365,6 +366,43 @@ export class ProvidersPage extends BasePage { await buttonByText.click(); await this.waitForPageLoad(); + + // Wait for either success (redirect to scans) or error message to appear + // The error container has multiple p.text-danger elements, we want the first one with the technical error + const errorMessage = this.page.locator("p.text-danger").first(); + + try { + // Wait up to 15 seconds for either the error message or redirect + await Promise.race([ + // Wait for error message to appear + errorMessage.waitFor({ state: "visible", timeout: 15000 }), + // Wait for redirect to scans page (success case) + this.page.waitForURL(/\/scans/, { timeout: 15000 }), + ]); + + // If we're still on test-connection page, check for error + if (/\/providers\/test-connection/.test(this.page.url())) { + const isErrorVisible = await errorMessage.isVisible().catch(() => false); + if (isErrorVisible) { + const errorText = await errorMessage.textContent(); + throw new Error( + `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, + ); + } + } + } catch (error) { + // If timeout or other error, check if error message is present + const isErrorVisible = await errorMessage.isVisible().catch(() => false); + if (isErrorVisible) { + const errorText = await errorMessage.textContent(); + throw new Error( + `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, + ); + } + // Re-throw original error if no error message found + throw error; + } + return; } @@ -534,8 +572,18 @@ export class ProvidersPage extends BasePage { // Verify the M365 credentials page is loaded await expect(this.page).toHaveTitle(/Prowler/); - await expect(this.m365StaticCredentialsRadio).toBeVisible(); - await expect(this.m365CertificateCredentialsRadio).toBeVisible(); + await expect(this.m365ClientIdInput).toBeVisible(); + await expect(this.m365ClientSecretInput).toBeVisible(); + await expect(this.m365TenantIdInput).toBeVisible(); + } + + async verifyM365CertificateCredentialsPageLoaded(): Promise { + // Verify the M365 certificate credentials page is loaded + + await expect(this.page).toHaveTitle(/Prowler/); + await expect(this.m365ClientIdInput).toBeVisible(); + await expect(this.m365TenantIdInput).toBeVisible(); + await expect(this.m365CertificateContentInput).toBeVisible(); } async verifyKubernetesCredentialsPageLoaded(): Promise { @@ -559,20 +607,10 @@ export class ProvidersPage extends BasePage { await expect(launchScanButton).toBeVisible(); } - async verifyProviderExists(providerUID: string): Promise { - // Filter search by providerUID - - await this.filterProvidersByProviderUID(providerUID); - // Verify if table has 1 row - if (!(await this.verifySingleRowForProviderUID(providerUID))) { - return false; - } - return true; - } - async verifyLoadProviderPageAfterNewProvider(): Promise { // Verify the provider page is loaded + await this.waitForPageLoad(); await expect(this.page).toHaveTitle(/Prowler/); await expect(this.providersTable).toBeVisible(); } @@ -593,95 +631,119 @@ export class ProvidersPage extends BasePage { return true; } - async filterProvidersByProviderUID(providerUID: string): Promise { - // Filter providers by providerUID + async deleteProviderIfExists(providerUID: string): Promise { + // Delete the provider if it exists - // Go to the providers page + // Navigate to providers page await this.goto(); + await expect(this.providersTable).toBeVisible({ timeout: 10000 }); - // Verify the providers table is visible - await expect(this.providersTable).toBeVisible(); - - // Get the search input + // Find and use the search input to filter the table const searchInput = this.page.getByPlaceholder(/search|filter/i); - if (!searchInput) { - throw new Error("No search input available"); - } - - // Clear existing content and type the providerUID - await searchInput.fill(""); - await searchInput.type(providerUID, { delay: 20 }); - - // Try to submit the filter if the input acts on Enter - try { - await searchInput.press("Enter"); - } catch (error) { - // Enter press might not be needed for all UIs - } - - // Wait for table to update + await expect(searchInput).toBeVisible({ timeout: 5000 }); + + // Clear and search for the specific provider + await searchInput.clear(); + await searchInput.fill(providerUID); + await searchInput.press("Enter"); + + // Wait for the table to finish loading/filtering await this.waitForPageLoad(); - } + + // Additional wait for React table to re-render with the server-filtered data + // The filtering happens on the server, but the table component needs time + // to process the response and update the DOM after network idle + await this.page.waitForTimeout(1500); - async actionDeleteProvider(providerUID: string): Promise { - // Delete the provider + // Get all rows from the table + const allRows = this.providersTable.locator("tbody tr"); + + // Helper function to check if a row is the "No results" row + const isNoResultsRow = async (row: Locator): Promise => { + const text = await row.textContent(); + return text?.includes("No results") || text?.includes("No data") || false; + }; - // Filter search by providerUID - await this.filterProvidersByProviderUID(providerUID); + // Helper function to find the row with the specific UID + const findProviderRow = async (): Promise => { + const count = await allRows.count(); + + for (let i = 0; i < count; i++) { + const row = allRows.nth(i); + + // Skip "No results" rows + if (await isNoResultsRow(row)) { + continue; + } + + // Check if this row contains the UID in the UID column (column 3) + const uidCell = row.locator("td").nth(3); + const uidText = await uidCell.textContent(); + + if (uidText?.includes(providerUID)) { + return row; + } + } + + return null; + }; - // Verify the providers table is visible - await expect(this.providersTable).toBeVisible(); + // Wait for filtering to complete (max 0 or 1 data rows) + await expect(async () => { + const targetRow = await findProviderRow(); + const count = await allRows.count(); + + // Count only real data rows (not "No results") + let dataRowCount = 0; + for (let i = 0; i < count; i++) { + if (!(await isNoResultsRow(allRows.nth(i)))) { + dataRowCount++; + } + } + + // Should have 0 or 1 data row + expect(dataRowCount).toBeLessThanOrEqual(1); + }).toPass({ timeout: 20000 }); - // Find the first row in the filtered results - const firstRow = this.providersTable.locator("tbody tr").first(); + // Find the provider row + const targetRow = await findProviderRow(); + + if (!targetRow) { + // Provider not found, nothing to delete + // Navigate back to providers page to ensure clean state + await this.goto(); + await expect(this.providersTable).toBeVisible({ timeout: 10000 }); + return; + } - // Verify the first row is visible - await expect(firstRow).toBeVisible(); - - // Get button in the last cell - const lastCell = firstRow.locator("td").last(); - const actionButton = lastCell.locator("button").first(); - - // Verify the action button is visible + // Find and click the action button (last cell = actions column) + const actionButton = targetRow.locator("td").last().locator("button").first(); + await expect(actionButton).toBeVisible({ timeout: 5000 }); await actionButton.click(); - // Wait for the dropdown menu to appear - await this.page.waitForSelector('[role="menu"], [role="menuitem"]', { - timeout: 5000, - }); - - // Find the delete menu item + // Wait for dropdown menu to appear and find delete option const deleteMenuItem = this.page.getByRole("menuitem", { name: /delete.*provider/i, }); + await expect(deleteMenuItem).toBeVisible({ timeout: 5000 }); + await deleteMenuItem.click(); - // Verify the delete menu item is visible - await expect(deleteMenuItem).toBeVisible(); + // Wait for confirmation modal to appear + const modal = this.page.locator('[role="dialog"], .modal, [data-testid*="modal"]').first(); + await expect(modal).toBeVisible({ timeout: 10000 }); - // Click the delete menu item with force to handle unstable elements - await deleteMenuItem.click({ force: true }); + // Find and click the delete confirmation button + await expect(this.deleteProviderConfirmationButton).toBeVisible({ timeout: 5000 }); + await this.deleteProviderConfirmationButton.click(); - // Wait for the delete confirmation modal to appear - await this.page.waitForSelector( - '[role="dialog"], .modal, [data-testid*="modal"]', - { timeout: 10000 }, - ); + // Wait for modal to close (this indicates deletion was initiated) + await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Find the delete confirmation button with multiple approaches - let deleteButton = this.deleteProviderConfirmationButton; - - await expect(deleteButton).toBeVisible(); - - // Click the delete button with force to handle unstable elements - await deleteButton.click({ force: true }); - - // Wait for the modal to disappear and the page to update - const modalToCheck = this.page - .locator('[role="dialog"], .modal, [data-testid*="modal"]') - .first(); - - // Verify the modal is not visible - await expect(modalToCheck).not.toBeVisible(); + // Wait for page to reload await this.waitForPageLoad(); + + // Navigate back to providers page to ensure clean state + await this.goto(); + await expect(this.providersTable).toBeVisible({ timeout: 10000 }); } } diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 695f7a5cd1..5dfd81a731 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -15,13 +15,14 @@ import { KubernetesProviderCredential, KUBERNETES_CREDENTIAL_OPTIONS, } from "./providers-page"; +import { ScansPage } from "../scans/scans-page"; import fs from "fs"; -test.describe.serial("Add Provider", () => { - test.describe("Add AWS Provider", () => { +test.describe("Add Provider", () => { + test.describe.serial("Add AWS Provider", () => { // Providers page object let providersPage: ProvidersPage; - + let scansPage: ScansPage; // Test data from environment variables const accountId = process.env.E2E_AWS_PROVIDER_ACCOUNT_ID; const accessKey = process.env.E2E_AWS_PROVIDER_ACCESS_KEY; @@ -39,7 +40,7 @@ test.describe.serial("Add Provider", () => { test.beforeEach(async ({ page }) => { providersPage = new ProvidersPage(page); // Clean up existing provider to ensure clean test state - await helpers.deleteProviderIfExists(page, accountId); + await providersPage.deleteProviderIfExists(accountId); }); // Use admin authentication for provider management @@ -108,7 +109,8 @@ test.describe.serial("Add Provider", () => { await providersPage.clickNext(); // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); @@ -176,14 +178,16 @@ test.describe.serial("Add Provider", () => { await providersPage.clickNext(); // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); }); - test.describe("Add AZURE Provider", () => { + test.describe.serial("Add AZURE Provider", () => { // Providers page object let providersPage: ProvidersPage; + let scansPage: ScansPage; // Test data from environment variables const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID; @@ -202,7 +206,7 @@ test.describe.serial("Add Provider", () => { test.beforeEach(async ({ page }) => { providersPage = new ProvidersPage(page); // Clean up existing provider to ensure clean test state - await helpers.deleteProviderIfExists(page, subscriptionId); + await providersPage.deleteProviderIfExists(subscriptionId); }); // Use admin authentication for provider management @@ -258,15 +262,17 @@ test.describe.serial("Add Provider", () => { await providersPage.verifyLaunchScanPageLoaded(); await providersPage.clickNext(); - // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); }); - test.describe("Add M365 Provider", () => { + test.describe.serial("Add M365 Provider", () => { // Providers page object let providersPage: ProvidersPage; + let scansPage: ScansPage; // Test data from environment variables const domainId = process.env.E2E_M365_DOMAIN_ID; @@ -284,7 +290,7 @@ test.describe.serial("Add Provider", () => { test.beforeEach(async ({ page }) => { providersPage = new ProvidersPage(page); // Clean up existing provider to ensure clean test state - await helpers.deleteProviderIfExists(page, domainId); + await providersPage.deleteProviderIfExists(domainId); }); // Use admin authentication for provider management @@ -342,6 +348,8 @@ test.describe.serial("Add Provider", () => { await providersPage.selectM365CredentialsType( M365_CREDENTIAL_OPTIONS.M365_CREDENTIALS, ); + + // Verify M365 credentials page is loaded await providersPage.verifyM365CredentialsPageLoaded(); // Fill static credentials details @@ -352,8 +360,9 @@ test.describe.serial("Add Provider", () => { await providersPage.verifyLaunchScanPageLoaded(); await providersPage.clickNext(); - // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); @@ -412,7 +421,9 @@ test.describe.serial("Add Provider", () => { await providersPage.selectM365CredentialsType( M365_CREDENTIAL_OPTIONS.M365_CERTIFICATE_CREDENTIALS, ); - await providersPage.verifyM365CredentialsPageLoaded(); + + // Verify M365 certificate credentials page is loaded + await providersPage.verifyM365CertificateCredentialsPageLoaded(); // Fill static credentials details await providersPage.fillM365CertificateCredentials(m365Credentials); @@ -422,15 +433,17 @@ test.describe.serial("Add Provider", () => { await providersPage.verifyLaunchScanPageLoaded(); await providersPage.clickNext(); - // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); }); - test.describe("Add Kubernetes Provider", () => { + test.describe.skip("Add Kubernetes Provider", () => { // Providers page object let providersPage: ProvidersPage; + let scansPage: ScansPage; // Test data from environment variables const context = process.env.E2E_KUBERNETES_CONTEXT; @@ -443,19 +456,12 @@ test.describe.serial("Add Provider", () => { ); } - // Read kubeconfig content from file - const kubeconfigContent = fs.readFileSync(kubeconfigPath, 'utf8'); - - // Verify kubeconfig file exists - if (!fs.existsSync(kubeconfigPath)) { - throw new Error(`Kubeconfig file not found at ${kubeconfigPath}`); - } // Setup before each test test.beforeEach(async ({ page }) => { providersPage = new ProvidersPage(page); // Clean up existing provider to ensure clean test state - await helpers.deleteProviderIfExists(page, context); + await providersPage.deleteProviderIfExists(context); }); // Use admin authentication for provider management @@ -474,6 +480,21 @@ test.describe.serial("Add Provider", () => { ], }, async ({ page }) => { + // Verify kubeconfig file exists + if (!fs.existsSync(kubeconfigPath)) { + throw new Error(`Kubeconfig file not found at ${kubeconfigPath}`); + } + + // Read kubeconfig content from file + let kubeconfigContent: string; + try { + kubeconfigContent = fs.readFileSync(kubeconfigPath, "utf8"); + } catch (error) { + throw new Error( + `Failed to read kubeconfig file at ${kubeconfigPath}: ${error}`, + ); + } + // Prepare test data for Kubernetes provider const kubernetesProviderData: KubernetesProviderData = { context: context, @@ -498,7 +519,9 @@ test.describe.serial("Add Provider", () => { await providersPage.selectKubernetesProvider(); // Fill provider details - await providersPage.fillKubernetesProviderDetails(kubernetesProviderData); + await providersPage.fillKubernetesProviderDetails( + kubernetesProviderData, + ); await providersPage.clickNext(); // Verify credentials page is loaded @@ -513,9 +536,9 @@ test.describe.serial("Add Provider", () => { await providersPage.clickNext(); // Wait for redirect to provider page - await providersPage.verifyLoadProviderPageAfterNewProvider(); + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); }, ); - }); }); diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts new file mode 100644 index 0000000000..beb7d3cea9 --- /dev/null +++ b/ui/tests/scans/scans-page.ts @@ -0,0 +1,28 @@ +import { Page, Locator, expect } from "@playwright/test"; +import { BasePage } from "../base-page"; + +// Scan page +export class ScansPage extends BasePage { + + // Main content elements + readonly scanTable: Locator; + constructor(page: Page) { + super(page); + + // Main content elements + this.scanTable = page.locator("table"); + + } + + // Navigation methods + async goto(): Promise { + await super.goto("/scans"); + } + + // Verification methods + async verifyPageLoaded(): Promise { + await expect(this.page).toHaveTitle(/Prowler/); + await expect(this.scanTable).toBeVisible(); + await this.waitForPageLoad(); + } +}