diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 7c290eeaa9..874e9eba50 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -249,6 +249,7 @@ modules: - ui/tests/profile/** - ui/tests/lighthouse/** - ui/tests/home/** + - ui/tests/navigation/** - ui/tests/attack-paths/** - name: api-serializers @@ -275,6 +276,7 @@ modules: - ui/tests/profile/** - ui/tests/lighthouse/** - ui/tests/home/** + - ui/tests/navigation/** - ui/tests/attack-paths/** - name: api-filters @@ -432,6 +434,14 @@ modules: e2e: - ui/tests/lighthouse/** + - name: ui-navigation + match: + - ui/components/layout/** + - ui/tests/navigation/** + tests: [] + e2e: + - ui/tests/navigation/** + - name: ui-overview match: - ui/components/overview/** @@ -464,6 +474,7 @@ modules: - ui/tests/profile/** - ui/tests/lighthouse/** - ui/tests/home/** + - ui/tests/navigation/** - ui/tests/attack-paths/** - name: ui-attack-paths diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 1475e5344a..888104386f 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -107,6 +107,7 @@ jobs: files: | api/** .github/workflows/api-tests.yml + codecov.yml files_ignore: | api/docs/** api/README.md diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 98654699e0..50332daf6d 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -36,6 +36,7 @@ jobs: needs: impact-analysis if: | github.repository == 'prowler-cloud/prowler' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) && (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') runs-on: ubuntu-latest env: @@ -118,6 +119,104 @@ jobs: env: NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }} + - name: Validate E2E prerequisites + shell: bash + run: | + declare -A required=() + + suite_selected() { + [[ "${RUN_ALL_TESTS}" == "true" ]] || + [[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]] + } + + require_vars() { + local variable + for variable in "$@"; do + required["${variable}"]=1 + done + } + + if suite_selected auth || suite_selected providers || + suite_selected invitations || suite_selected scans || + suite_selected navigation; then + require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD + fi + + if suite_selected sign-up; then + require_vars E2E_NEW_USER_PASSWORD + fi + + if suite_selected invitations; then + require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID + fi + + if suite_selected scans; then + require_vars \ + E2E_AWS_PROVIDER_ACCOUNT_ID \ + E2E_AWS_PROVIDER_ACCESS_KEY \ + E2E_AWS_PROVIDER_SECRET_KEY + fi + + if suite_selected providers; then + require_vars \ + E2E_AWS_PROVIDER_ACCOUNT_ID \ + E2E_AWS_PROVIDER_ACCESS_KEY \ + E2E_AWS_PROVIDER_SECRET_KEY \ + E2E_AWS_PROVIDER_ROLE_ARN \ + E2E_AZURE_SUBSCRIPTION_ID \ + E2E_AZURE_CLIENT_ID \ + E2E_AZURE_SECRET_ID \ + E2E_AZURE_TENANT_ID \ + E2E_M365_DOMAIN_ID \ + E2E_M365_CLIENT_ID \ + E2E_M365_SECRET_ID \ + E2E_M365_TENANT_ID \ + E2E_M365_CERTIFICATE_CONTENT \ + E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \ + E2E_GCP_PROJECT_ID \ + E2E_GITHUB_APP_ID \ + E2E_GITHUB_BASE64_APP_PRIVATE_KEY \ + E2E_GITHUB_USERNAME \ + E2E_GITHUB_PERSONAL_ACCESS_TOKEN \ + E2E_GITHUB_ORGANIZATION \ + E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \ + E2E_OCI_TENANCY_ID \ + E2E_OCI_USER_ID \ + E2E_OCI_FINGERPRINT \ + E2E_OCI_KEY_CONTENT \ + E2E_ALIBABACLOUD_ACCOUNT_ID \ + E2E_ALIBABACLOUD_ACCESS_KEY_ID \ + E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \ + E2E_ALIBABACLOUD_ROLE_ARN \ + E2E_OKTA_DOMAIN \ + E2E_OKTA_CLIENT_ID \ + E2E_OKTA_BASE64_PRIVATE_KEY \ + E2E_GOOGLEWORKSPACE_CUSTOMER_ID \ + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \ + E2E_GOOGLEWORKSPACE_DELEGATED_USER \ + E2E_VERCEL_TEAM_ID \ + E2E_VERCEL_API_TOKEN + fi + + missing=() + if (( ${#required[@]} > 0 )); then + while IFS= read -r variable; do + [[ -z "${!variable:-}" ]] && missing+=("${variable}") + done < <(printf '%s\n' "${!required[@]}" | sort) + fi + + if (( ${#missing[@]} > 0 )); then + echo "Missing required E2E variables:" + printf ' - %s\n' "${missing[@]}" + { + echo "## Missing E2E prerequisites" + printf -- "- \`%s\`\n" "${missing[@]}" + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + echo "E2E prerequisite preflight passed." + - name: Create k8s Kind Cluster uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1 with: @@ -304,6 +403,24 @@ jobs: run: | docker compose down -v || true + # Fork pull requests cannot access the secrets required by the E2E suites. + fork-e2e-unavailable: + needs: impact-analysis + if: | + github.repository == 'prowler-cloud/prowler' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork == true && + (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Report unavailable E2E tests + run: | + echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY" + # Skip job - provides clear feedback when no E2E tests needed skip-e2e: needs: impact-analysis diff --git a/.gitignore b/.gitignore index 6c11a8698c..4545146688 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,5 @@ GEMINI.md # Docker docker-compose.override.yml docker-compose-dev.override.yml +# Local Pi runtime state +.atl/ diff --git a/codecov.yml b/codecov.yml index ca31ca8dd4..d7e9a1c312 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,5 +7,11 @@ component_management: paths: - "api/**" +flags: + api: + paths: + - "api/**" + carryforward: true + comment: layout: "header, diff, flags, components" diff --git a/tests/github/api_tests_workflow_test.py b/tests/github/api_tests_workflow_test.py new file mode 100644 index 0000000000..e08df1db39 --- /dev/null +++ b/tests/github/api_tests_workflow_test.py @@ -0,0 +1,29 @@ +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[2] +WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/api-tests.yml" + + +def _indented_block(text, heading): + lines = text.splitlines() + start = lines.index(heading) + indentation = len(heading) - len(heading.lstrip()) + end = len(lines) + + for index in range(start + 1, len(lines)): + line = lines[index] + if line.strip() and len(line) - len(line.lstrip()) <= indentation: + end = index + break + + return lines[start:end] + + +def test_codecov_configuration_changes_run_api_tests(): + workflow = WORKFLOW_PATH.read_text() + changed_files_step = _indented_block( + workflow, " - name: Check for API changes" + ) + files = _indented_block("\n".join(changed_files_step), " files: |") + + assert "codecov.yml" in {line.strip() for line in files[1:]} diff --git a/tests/github/ui_e2e_workflow_test.py b/tests/github/ui_e2e_workflow_test.py new file mode 100644 index 0000000000..26c61c50c6 --- /dev/null +++ b/tests/github/ui_e2e_workflow_test.py @@ -0,0 +1,79 @@ +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[2] +WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/ui-e2e-tests-v2.yml" +NODE_IMAGE_DIGEST = ( + "sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f" +) + + +def _indented_block(text, heading): + lines = text.splitlines() + start = lines.index(heading) + indentation = len(heading) - len(heading.lstrip()) + end = len(lines) + + for index in range(start + 1, len(lines)): + line = lines[index] + if line.strip() and len(line) - len(line.lstrip()) <= indentation: + end = index + break + + return "\n".join(lines[start:end]) + + +def _multiline_value(block, key): + lines = block.splitlines() + heading = next(line for line in lines if line.strip() == f"{key}: |") + value_block = _indented_block(block, heading) + return "\n".join(value_block.splitlines()[1:]) + + +def _normalize(expression): + return " ".join(expression.split()) + + +def test_fork_pull_requests_use_explicit_skip_route(): + workflow = WORKFLOW_PATH.read_text() + e2e_job = _indented_block(workflow, " e2e-tests:") + fork_job = _indented_block(workflow, " fork-e2e-unavailable:") + + assert _normalize(_multiline_value(e2e_job, "if")) == ( + "github.repository == 'prowler-cloud/prowler' && " + "(github.event_name != 'pull_request' || " + "github.event.pull_request.head.repo.fork == false) && " + "(needs.impact-analysis.outputs.has-ui-e2e == 'true' || " + "needs.impact-analysis.outputs.run-all == 'true')" + ) + assert _normalize(_multiline_value(fork_job, "if")) == ( + "github.repository == 'prowler-cloud/prowler' && " + "github.event_name == 'pull_request' && " + "github.event.pull_request.head.repo.fork == true && " + "(needs.impact-analysis.outputs.has-ui-e2e == 'true' || " + "needs.impact-analysis.outputs.run-all == 'true')" + ) + assert _indented_block(fork_job, " permissions:") == ( + " permissions:\n contents: read" + ) + + reporting_step = _indented_block( + fork_job, " - name: Report unavailable E2E tests" + ) + assert "GITHUB_STEP_SUMMARY" in reporting_step + assert ( + "UI E2E tests require repository secrets and cannot run for fork pull requests." + in reporting_step + ) + + prerequisite_step = _indented_block( + e2e_job, " - name: Validate E2E prerequisites" + ) + assert "IS_FORK_PR" not in prerequisite_step + assert "exit 0" not in prerequisite_step + + +def test_docker_node_image_matches_nvmrc(): + node_version = (REPOSITORY_ROOT / "ui/.nvmrc").read_text().strip() + dockerfile = (REPOSITORY_ROOT / "ui/Dockerfile").read_text() + + assert f"FROM node:{node_version}-alpine@{NODE_IMAGE_DIGEST} AS base" in dockerfile diff --git a/ui/.nvmrc b/ui/.nvmrc index 3fe3b1570a..5bf4400f22 100644 --- a/ui/.nvmrc +++ b/ui/.nvmrc @@ -1 +1 @@ -24.13.0 +24.15.0 diff --git a/ui/Dockerfile b/ui/Dockerfile index b3677bfbc3..6bd8c9d8d1 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,5 +1,5 @@ # Keep in sync with ui/.nvmrc. -FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS base LABEL maintainer="https://github.com/prowler-cloud" diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 67d647448f..6bb4f2a068 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -129,76 +129,50 @@ export async function addAWSProvider( } /** - * Waits for the providers page to settle and reports whether the data table is - * present. With zero providers the page renders a full-page empty state - * ("No Providers Configured") instead of the table, so callers must not assume - * the table is always there. + * Finds a provider after the filtered providers page reaches a loaded state. + * The loading skeleton also renders a table, so table visibility alone cannot + * prove that provider data is ready. */ -async function providersTableVisibleOrEmptyState( +async function findProviderRow( page: ProvidersPage, -): Promise { + providerUID: string, +): Promise { + await page.page.goto( + `/providers?filter%5Bsearch%5D=${encodeURIComponent(providerUID)}`, + ); + + const providerRow = page.providersTable + .getByRole("row") + .filter({ hasText: providerUID }) + .first(); + const noResults = page.providersTable.getByRole("cell", { + name: "No results.", + exact: true, + }); const emptyState = page.page.getByRole("region", { name: /no providers configured/i, }); - await expect(page.providersTable.or(emptyState)).toBeVisible({ + + await expect(providerRow.or(noResults).or(emptyState)).toBeVisible({ timeout: 10000, }); - return page.providersTable.isVisible().catch(() => false); + + if (await providerRow.isVisible().catch(() => false)) { + return providerRow; + } + + return null; } export async function deleteProviderIfExists( page: ProvidersPage, providerUID: string, ): Promise { - // Delete the provider if it exists - - // Navigate to providers page - await page.goto(); - // With zero providers the page shows the empty state, not the table, so there - // is nothing to delete. - if (!(await providersTableVisibleOrEmptyState(page))) { - return; - } - - const allRows = page.providersTable.locator("tbody tr"); - - const isNoResultsRow = async (row: Locator): Promise => { - const text = await row.textContent(); - return text?.includes("No results") || text?.includes("No data") || false; - }; - - const findProviderRow = async (): Promise => { - const rowByText = page.providersTable - .locator("tbody tr") - .filter({ hasText: providerUID }) - .first(); - if (await rowByText.isVisible().catch(() => false)) { - return rowByText; - } - - const count = await allRows.count(); - for (let i = 0; i < count; i++) { - const row = allRows.nth(i); - if (await isNoResultsRow(row)) { - continue; - } - const rowText = await row.textContent(); - if (rowText?.includes(providerUID)) { - return row; - } - } - - return null; - }; - // Find the provider row - const targetRow = await findProviderRow(); + const targetRow = await findProviderRow(page, providerUID); if (!targetRow) { // Provider not found, nothing to delete - // Navigate back to providers page to ensure clean state - await page.goto(); - await providersTableVisibleOrEmptyState(page); return; } @@ -239,8 +213,17 @@ export async function deleteProviderIfExists( // Wait for modal to close (this indicates deletion was initiated) await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Navigate back to providers page to ensure clean state. Deleting the last - // provider reveals the empty state instead of an empty table. - await page.goto(); - await providersTableVisibleOrEmptyState(page); + // The success notification is shown only after the delete request completes. + await expect( + page.page.getByText("The provider was removed successfully.", { + exact: true, + }), + ).toBeVisible({ timeout: 10000 }); + + // Reload a server-filtered view and prove the provider no longer exists. + const deletedProviderRow = await findProviderRow(page, providerUID); + expect( + deletedProviderRow, + `Provider ${providerUID} still exists after deletion`, + ).toBeNull(); } diff --git a/ui/tests/home/home-page.ts b/ui/tests/home/home-page.ts index e61be49b76..44bf6b5107 100644 --- a/ui/tests/home/home-page.ts +++ b/ui/tests/home/home-page.ts @@ -40,8 +40,8 @@ export class HomePage extends BasePage { this.dashboardCards = page.locator('[data-testid="dashboard-card"]'); this.overviewSection = page.locator('[data-testid="overview-section"]'); - // UI elements - this.logo = page.locator('svg[width="300"]'); + // UI elements - the sidebar logo is the home link's accessible content. + this.logo = page.getByRole("link", { name: "Prowler home" }); } // Navigation methods diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index f51b251c2c..667aaaed85 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -107,7 +107,11 @@ export class ScansPage extends BasePage { await expect(this.launchScanDialog).toBeVisible(); await this.scanProviderSelect.click(); await this.scanProviderSearchInput.fill(uid); - await this.scanProviderOption.first().click(); + + const providerOption = this.scanProviderOption.first(); + await expect(providerOption).toBeVisible(); + await expect(providerOption).toHaveAttribute("aria-disabled", "false"); + await providerOption.click(); } async fillScanNote(note: string): Promise { diff --git a/ui/tests/sign-in-base/sign-in-base-page.ts b/ui/tests/sign-in-base/sign-in-base-page.ts index 7499f0e1f1..8966e26831 100644 --- a/ui/tests/sign-in-base/sign-in-base-page.ts +++ b/ui/tests/sign-in-base/sign-in-base-page.ts @@ -67,8 +67,10 @@ export class SignInPage extends BasePage { this.signUpLink = page.getByRole("link", { name: "Sign up" }); this.backButton = page.getByRole("button", { name: "Back" }); - // UI elements - title is a

element, not a heading - this.logo = page.getByRole("img", { name: /Prowler/ }); + // UI elements - ProwlerBrand exposes the deployed brand variant as an image. + this.logo = page.getByRole("img", { + name: /^Prowler (Cloud|Local Server)$/, + }); // Use text matching with exact=true to avoid matching other elements this.pageTitle = page.getByText("Welcome back", { exact: true });