mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): stabilize cloud e2e prerequisites (#12024)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -107,6 +107,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -173,3 +173,5 @@ GEMINI.md
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
|
||||
@@ -7,5 +7,11 @@ component_management:
|
||||
paths:
|
||||
- "api/**"
|
||||
|
||||
flags:
|
||||
api:
|
||||
paths:
|
||||
- "api/**"
|
||||
carryforward: true
|
||||
|
||||
comment:
|
||||
layout: "header, diff, flags, components"
|
||||
|
||||
@@ -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:]}
|
||||
@@ -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
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+40
-57
@@ -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<boolean> {
|
||||
providerUID: string,
|
||||
): Promise<Locator | null> {
|
||||
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<void> {
|
||||
// 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<boolean> => {
|
||||
const text = await row.textContent();
|
||||
return text?.includes("No results") || text?.includes("No data") || false;
|
||||
};
|
||||
|
||||
const findProviderRow = async (): Promise<Locator | null> => {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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 <p> 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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user