diff --git a/.ci/pipelines/env_variables.sh b/.ci/pipelines/env_variables.sh index f417ccd166..0e2e3bb579 100755 --- a/.ci/pipelines/env_variables.sh +++ b/.ci/pipelines/env_variables.sh @@ -142,6 +142,21 @@ AZURE_DB_4_HOST=$(cat /tmp/secrets/AZURE_DB_4_HOST) # Store paths instead of content to avoid "Argument list too long" shell errors RDS_DB_CERTIFICATES_PATH="/tmp/secrets/rds-db-certificates.pem" AZURE_DB_CERTIFICATES_PATH="/tmp/secrets/azure-db-certificates.pem" +## Google Cloud SQL credentials (Vault-populated in CI) +## Auth Proxy sidecar uses instance connection names (project:region:instance). +## Public hosts + server CA PEM are used for clearDatabase (same as RDS/Azure). +CLOUDSQL_USER=$(cat /tmp/secrets/CLOUDSQL_USER 2> /dev/null || true) +CLOUDSQL_PASSWORD=$(cat /tmp/secrets/CLOUDSQL_PASSWORD 2> /dev/null || true) +CLOUDSQL_INSTANCE_1=$(cat /tmp/secrets/CLOUDSQL_INSTANCE_1 2> /dev/null || true) +CLOUDSQL_INSTANCE_2=$(cat /tmp/secrets/CLOUDSQL_INSTANCE_2 2> /dev/null || true) +CLOUDSQL_INSTANCE_3=$(cat /tmp/secrets/CLOUDSQL_INSTANCE_3 2> /dev/null || true) +CLOUDSQL_INSTANCE_4=$(cat /tmp/secrets/CLOUDSQL_INSTANCE_4 2> /dev/null || true) +CLOUDSQL_1_HOST=$(cat /tmp/secrets/CLOUDSQL_1_HOST 2> /dev/null || true) +CLOUDSQL_2_HOST=$(cat /tmp/secrets/CLOUDSQL_2_HOST 2> /dev/null || true) +CLOUDSQL_3_HOST=$(cat /tmp/secrets/CLOUDSQL_3_HOST 2> /dev/null || true) +CLOUDSQL_4_HOST=$(cat /tmp/secrets/CLOUDSQL_4_HOST 2> /dev/null || true) +CLOUDSQL_DB_CERTIFICATES_PATH="/tmp/secrets/cloudsql-db-certificates.pem" +CLOUDSQL_SERVICE_ACCOUNT_JSON_PATH="/tmp/secrets/cloudsql-service-account.json" JUNIT_RESULTS="junit-results.xml" diff --git a/.ci/pipelines/jobs/auth-providers.sh b/.ci/pipelines/jobs/auth-providers.sh index 240acddaba..a26c3da26b 100644 --- a/.ci/pipelines/jobs/auth-providers.sh +++ b/.ci/pipelines/jobs/auth-providers.sh @@ -31,5 +31,7 @@ handle_auth_providers() { export LOGS_FOLDER log::info "Running tests ${AUTH_PROVIDERS_RELEASE} in ${AUTH_PROVIDERS_NAMESPACE}" - testing::run_tests "${AUTH_PROVIDERS_RELEASE}" "${AUTH_PROVIDERS_NAMESPACE}" "${PW_PROJECT_SHOWCASE_AUTH_PROVIDERS}" "https://${K8S_CLUSTER_ROUTER_BASE}" || true + # The auth-provider harness deploys its own test instance, so leave BASE_URL + # unset and let Playwright global setup no-op instead of probing the router. + testing::run_tests "${AUTH_PROVIDERS_RELEASE}" "${AUTH_PROVIDERS_NAMESPACE}" "${PW_PROJECT_SHOWCASE_AUTH_PROVIDERS}" "" || true } diff --git a/.ci/pipelines/jobs/ocp-nightly.sh b/.ci/pipelines/jobs/ocp-nightly.sh index 5fb725297c..04ab50c638 100644 --- a/.ci/pipelines/jobs/ocp-nightly.sh +++ b/.ci/pipelines/jobs/ocp-nightly.sh @@ -47,11 +47,11 @@ run_standard_deployment_tests() { } run_runtime_config_change_tests() { - # Runtime tests handle their own deployment via TypeScript (runtime-deploy.ts). - # The first test file (config-map.spec.ts) calls ensureRuntimeDeployed() which: - # - Creates the namespace - # - Deploys RHDH with Helm + internal PostgreSQL sub-chart - # - Configures schema-mode env vars for port-forwarding + # Runtime tests self-deploy from Playwright global setup when + # RUNTIME_AUTO_DEPLOY=true. Pass the predicted route as BASE_URL so + # playwright.config.ts freezes a usable use.baseURL before globalSetup + # (Playwright resolves config before globalSetup runs). globalSetup then + # deploys via ensureRuntimeDeployed() and healthchecks that URL. # Subsequent test files reuse the existing deployment (workers: 1). # # The CI wrapper only needs to set environment variables and invoke Playwright. @@ -62,9 +62,11 @@ run_runtime_config_change_tests() { # deploy branch was skipped and global-setup instead polled a route that # nothing had created yet - failing after 120s before any test could run. # ensureRuntimeDeployed() sets BASE_URL itself once the route exists. + # + # Scope RUNTIME_AUTO_DEPLOY to this invocation only — a lasting export would + # leak into later projects (e.g. sanity-plugins) and stomp BASE_URL. export INSTALL_METHOD="helm" - export RUNTIME_AUTO_DEPLOY="true" - testing::run_tests "${RELEASE_NAME}" "${NAME_SPACE_RUNTIME}" "${PW_PROJECT_SHOWCASE_RUNTIME}" || true + RUNTIME_AUTO_DEPLOY=true testing::run_tests "${RELEASE_NAME}" "${NAME_SPACE_RUNTIME}" "${PW_PROJECT_SHOWCASE_RUNTIME}" || true } run_sanity_plugins_check() { diff --git a/.ci/pipelines/jobs/ocp-operator.sh b/.ci/pipelines/jobs/ocp-operator.sh index 13240ec159..71e446b268 100644 --- a/.ci/pipelines/jobs/ocp-operator.sh +++ b/.ci/pipelines/jobs/ocp-operator.sh @@ -68,11 +68,10 @@ initiate_operator_deployments_osd_gcp() { } run_operator_runtime_config_change_tests() { - # Runtime tests handle their own deployment via TypeScript (runtime-deploy.ts). - # The first test file (config-map.spec.ts) calls ensureRuntimeDeployed() which: - # - Creates the namespace - # - Deploys RHDH via the operator with internal PostgreSQL - # - Configures schema-mode env vars for port-forwarding + # Runtime tests self-deploy from Playwright global setup when + # RUNTIME_AUTO_DEPLOY=true. Pass the predicted route as BASE_URL so + # playwright.config.ts freezes a usable use.baseURL before globalSetup. + # globalSetup then deploys via ensureRuntimeDeployed() and healthchecks. # Subsequent test files reuse the existing deployment (workers: 1). # # INSTALL_METHOD=operator is already exported in handle_ocp_operator(). @@ -80,8 +79,10 @@ run_operator_runtime_config_change_tests() { # No URL is passed on purpose - see the same note in jobs/ocp-nightly.sh. # A pre-set BASE_URL makes global-setup.ts skip the deploy branch and poll a # route nothing has created yet; ensureRuntimeDeployed() sets BASE_URL itself. - export RUNTIME_AUTO_DEPLOY="true" - testing::run_tests "${RELEASE_NAME}" "${NAME_SPACE_RUNTIME}" "${PW_PROJECT_SHOWCASE_RUNTIME}" || true + # + # Scope RUNTIME_AUTO_DEPLOY to this invocation only — a lasting export would + # leak into later jobs and stomp BASE_URL. + RUNTIME_AUTO_DEPLOY=true testing::run_tests "${RELEASE_NAME}" "${NAME_SPACE_RUNTIME}" "${PW_PROJECT_SHOWCASE_RUNTIME}" || true } handle_ocp_operator() { diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac.yaml index e83e7e07d7..e32c9e84bf 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac.yaml @@ -44,3 +44,7 @@ spec: configMaps: - name: rbac-policy mountPath: /opt/app-root/src/rbac + # Disable default flavours (e.g. lightspeed) so install-dynamic-plugins + # cannot CrashLoop on operator-injected OCI pulls — same invariant as + # OperatorInstallProfile for Playwright-generated CRs. + flavours: [] diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac_K8s.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac_K8s.yaml index 4bbfbaa506..ef63cbb6f7 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac_K8s.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start-rbac_K8s.yaml @@ -49,3 +49,7 @@ spec: configMaps: - name: rbac-policy mountPath: /opt/app-root/src/rbac + # Disable default flavours (e.g. lightspeed) so install-dynamic-plugins + # cannot CrashLoop on operator-injected OCI pulls — same invariant as + # OperatorInstallProfile for Playwright-generated CRs. + flavours: [] diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start.yaml index f97aa1afb7..3ea32d3f74 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start.yaml @@ -42,3 +42,7 @@ spec: secrets: - name: rhdh-secrets - name: redis-secret + # Disable default flavours (e.g. lightspeed) so install-dynamic-plugins + # cannot CrashLoop on operator-injected OCI pulls — same invariant as + # OperatorInstallProfile for Playwright-generated CRs. + flavours: [] diff --git a/.ci/pipelines/resources/rhdh-operator/rhdh-start_K8s.yaml b/.ci/pipelines/resources/rhdh-operator/rhdh-start_K8s.yaml index d935424fd1..119fef3c69 100644 --- a/.ci/pipelines/resources/rhdh-operator/rhdh-start_K8s.yaml +++ b/.ci/pipelines/resources/rhdh-operator/rhdh-start_K8s.yaml @@ -47,3 +47,7 @@ spec: secrets: - name: rhdh-secrets - name: redis-secret + # Disable default flavours (e.g. lightspeed) so install-dynamic-plugins + # cannot CrashLoop on operator-injected OCI pulls — same invariant as + # OperatorInstallProfile for Playwright-generated CRs. + flavours: [] diff --git a/.ci/pipelines/utils.sh b/.ci/pipelines/utils.sh index 6ebe359752..51529727fe 100755 --- a/.ci/pipelines/utils.sh +++ b/.ci/pipelines/utils.sh @@ -357,23 +357,89 @@ apply_yaml_files() { deploy_test_backstage_customization_provider() { local project=$1 - log::info "Deploying test-backstage-customization-provider in namespace ${project}" + local app_name="test-backstage-customization-provider" + log::info "Deploying ${app_name} in namespace ${project}" # Check if the buildconfig already exists - if ! oc get buildconfig test-backstage-customization-provider -n "${project}" > /dev/null 2>&1; then + if ! oc get buildconfig "${app_name}" -n "${project}" > /dev/null 2>&1; then # Get latest nodejs UBI9 tag from cluster, fallback to 18-ubi8 local nodejs_tag nodejs_tag=$(oc get imagestream nodejs -n openshift -o jsonpath='{.spec.tags[*].name}' 2> /dev/null \ | tr ' ' '\n' | grep -E '^[0-9]+-ubi9$' | sort -t'-' -k1 -n | tail -1) nodejs_tag="${nodejs_tag:-18-ubi8}" - log::info "Creating new app for test-backstage-customization-provider using nodejs:${nodejs_tag}" + log::info "Creating new app for ${app_name} using nodejs:${nodejs_tag}" oc new-app "openshift/nodejs:${nodejs_tag}~https://github.com/janus-qe/test-backstage-customization-provider" --namespace="${project}" else - log::warn "BuildConfig for test-backstage-customization-provider already exists in ${project}. Skipping new-app creation." + log::warn "BuildConfig for ${app_name} already exists in ${project}. Skipping new-app creation." fi - log::info "Exposing service for test-backstage-customization-provider" - oc expose svc/test-backstage-customization-provider --namespace="${project}" + # oc new-app creates a Deployment whose first ReplicaSet often has image " " + # until the S2I build finishes. Fail closed until a Complete build exists and + # the Deployment image is non-whitespace. Skip the build wait when a prior + # successful deploy already left a real image (builds may have been pruned). + local image image_trimmed + image=$(oc get "deployment/${app_name}" -n "${project}" \ + -o jsonpath='{.spec.template.spec.containers[0].image}' 2> /dev/null || true) + image_trimmed="${image//[[:space:]]/}" + if [[ -n "${image_trimmed}" ]]; then + log::info "${app_name} already has image '${image_trimmed}'; waiting for rollout only" + oc rollout status "deployment/${app_name}" -n "${project}" --timeout=300s + else + log::info "Waiting for ${app_name} build to appear in ${project}" + local latest_build="" + local attempt + for ((attempt = 1; attempt <= 60; attempt++)); do + latest_build=$(oc get builds -n "${project}" -l "buildconfig=${app_name}" \ + --sort-by=.metadata.creationTimestamp \ + -o jsonpath='{.items[-1:].metadata.name}' 2> /dev/null || true) + if [[ -n "${latest_build}" ]]; then + break + fi + sleep 5 + done + if [[ -z "${latest_build}" ]]; then + log::error "No ${app_name} build appeared in ${project} within 300s" + return 1 + fi + + log::info "Waiting for build/${latest_build} to Complete in ${project}" + local build_phase="" + for ((attempt = 1; attempt <= 120; attempt++)); do + build_phase=$(oc get "build/${latest_build}" -n "${project}" \ + -o jsonpath='{.status.phase}' 2> /dev/null || echo "") + if [[ "${build_phase}" == "Complete" ]]; then + break + fi + if [[ "${build_phase}" == "Failed" || "${build_phase}" == "Error" || "${build_phase}" == "Cancelled" ]]; then + log::error "Build ${latest_build} ended in phase ${build_phase}" + oc logs "build/${latest_build}" -n "${project}" --tail=50 || true + return 1 + fi + sleep 5 + done + if [[ "${build_phase}" != "Complete" ]]; then + log::error "Timed out waiting for build/${latest_build} (last phase: ${build_phase:-unknown})" + return 1 + fi + + log::info "Waiting for ${app_name} deployment to roll out in ${project}" + oc rollout status "deployment/${app_name}" -n "${project}" --timeout=300s + + image=$(oc get "deployment/${app_name}" -n "${project}" \ + -o jsonpath='{.spec.template.spec.containers[0].image}' 2> /dev/null || true) + image_trimmed="${image//[[:space:]]/}" + if [[ -z "${image_trimmed}" ]]; then + log::error "${app_name} deployment still has empty/whitespace image: '${image}'" + return 1 + fi + fi + + if ! oc get route "${app_name}" -n "${project}" > /dev/null 2>&1; then + log::info "Exposing service for ${app_name}" + oc expose svc/"${app_name}" --namespace="${project}" + else + log::info "Route for ${app_name} already exists in ${project}" + fi } deploy_redis_cache() { diff --git a/.ci/pipelines/value_files/values_showcase-rbac.yaml b/.ci/pipelines/value_files/values_showcase-rbac.yaml index 35902f028c..bd6efab627 100644 --- a/.ci/pipelines/value_files/values_showcase-rbac.yaml +++ b/.ci/pipelines/value_files/values_showcase-rbac.yaml @@ -62,6 +62,14 @@ global: rbac: parent: default.admin icon: rbacIcon + + # Disable chart/operator-injected lightspeed plugins ({{inherit}} cannot resolve + # against the catalog index). Restores #4791 after it was dropped in #4869. + - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed:{{ "{{" }}inherit{{ "}}" }}' + enabled: false + - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed-backend:{{ "{{" }}inherit{{ "}}" }}' + enabled: false + # -- Upstream Backstage [chart configuration](https://github.com/backstage/charts/blob/main/charts/backstage/values.yaml) # @default -- Use Openshift compatible settings upstream: diff --git a/.ci/pipelines/value_files/values_showcase.yaml b/.ci/pipelines/value_files/values_showcase.yaml index 8704c69dcd..46e922366a 100644 --- a/.ci/pipelines/value_files/values_showcase.yaml +++ b/.ci/pipelines/value_files/values_showcase.yaml @@ -54,6 +54,14 @@ global: - package: "@backstage-community/plugin-todo@0.2.42" enabled: true integrity: sha512-agmfwxHkZPy0zaXzjMKY9Us9l7J2og+z7p2lDWQBmlJ1KZRo6OBQdnlG1mTEryfEEl/bx5Ko+f1PhFj2/BmiIQ== + + # Disable chart/operator-injected lightspeed plugins ({{inherit}} cannot resolve + # against the catalog index). Restores #4791 after it was dropped in #4869. + - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed:{{ "{{" }}inherit{{ "}}" }}' + enabled: false + - package: 'oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed-backend:{{ "{{" }}inherit{{ "}}" }}' + enabled: false + # -- Upstream Backstage [chart configuration](https://github.com/backstage/charts/blob/main/charts/backstage/values.yaml) upstream: nameOverride: developer-hub diff --git a/.claude/rules/ci-e2e-testing.md b/.claude/rules/ci-e2e-testing.md index 2c54766ef6..52fcfaa020 100644 --- a/.claude/rules/ci-e2e-testing.md +++ b/.claude/rules/ci-e2e-testing.md @@ -5,6 +5,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## Table of Contents - [E2E Testing Framework](#e2e-testing-framework) +- [Verifying E2E Changes on a PR (`/test`)](#verifying-e2e-changes-on-a-pr-test) - [CI/CD Infrastructure](#cicd-infrastructure) - [Key Dependencies and Common Issues](#key-dependencies-and-common-issues) - [Documentation References](#documentation-references) @@ -12,6 +13,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## E2E Testing Framework ### Technology Stack + - **Testing Framework**: Playwright with TypeScript - **Node.js Version**: 22 - **Package Manager**: Yarn 3.8.7 @@ -23,6 +25,7 @@ Current versions for e2e-tests are defined in `e2e-tests/package.json` ### Test Structure and Organization #### Directory Structure + ```text e2e-tests/ ├── playwright/ @@ -186,6 +189,40 @@ yarn prettier:fix # Prettier fixing **Note**: The CI pipeline no longer uses yarn script aliases. Instead, it runs Playwright directly with `yarn playwright test --project=`. This decouples the namespace from the test project name, enabling more flexible namespace and test project reuse. +## Verifying E2E Changes on a PR (`/test`) + +**Required practice:** when a PR changes e2e tests or shared e2e infrastructure, trigger the Prow jobs that exercise those paths and wait for them to finish. Do not merge (or call the change verified) based only on unit/lint or the default mandatory PR check if the change affects other jobs. + +Nightlies and optional jobs are **not** all run automatically on every PR. Shared refactors (fixtures, `global-setup.ts`, `playwright.config.ts`, deploy helpers, auth harness, localization) can break jobs that never ran on the PR unless you trigger them. + +### How to trigger + +1. Comment `/test ?` on the PR — the `openshift-ci` bot lists available short job names for that branch/PR. +2. Comment `/test ` for each affected job (use **only** names from the bot list; never invent full `pull-ci-...` names). +3. Wait for the jobs to complete and confirm they still pass, or are not worse than before the change. + +```bash +gh pr comment --repo redhat-developer/rhdh --body "/test ?" +gh pr comment --repo redhat-developer/rhdh --body "/test e2e-ocp-helm-nightly" +``` + +- Prefer specific jobs over `/test all` (wastes cluster capacity). +- External contributors need `/ok-to-test` from an org member before `/test` works — see [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md). +- Detailed bot polling and job-name matching for fix PRs: `e2e-submit-and-review` skill. + +### What to trigger (by change area) + +| If you changed… | Trigger at least… | +|-----------------|-------------------| +| Shared fixtures, `global-setup.ts`, `wait-for-rhdh-ready`, browser/session helpers | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` | +| `showcase-runtime`, `runtime-deploy.ts`, `runtime-config.ts`, config-map / external-db / schema-mode specs | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` (runtime project) | +| `auth-providers/**`, auth harness / `rhdh-deployment` | `e2e-ocp-operator-auth-providers-nightly` | +| Localization specs, `locale.ts`, localization project config | `e2e-ocp-helm-localization-nightly` | +| Smoke / homepage / catalog / most showcase specs | Mandatory `e2e-ocp-helm` (auto on ready PRs); add nightly if the change is cross-cutting | +| K8s-only paths | Matching `e2e-aks-*` / `e2e-eks-*` / `e2e-gke-*` job from `/test ?` | + +Job → Playwright project mapping lives in the `e2e-fix-workflow` rule. Human-oriented CI trigger docs: [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md) and [docs/e2e-tests/CI-medic-guide.md](docs/e2e-tests/CI-medic-guide.md). + ### Environment Variables All the important environment variables are sourced in `.ci/pipelines/env_variables.sh` @@ -211,15 +248,18 @@ Playwright configuration (`e2e-tests/playwright.config.ts`): The `showcase-auth-providers` project has several significant differences from other showcase projects, particularly in deployment and configuration: #### **Namespace and Release Management** + - **Other Showcase Projects**: Use standard namespace patterns (`showcase-ci-nightly`, `showcase-rbac-nightly`) - **showcase-auth-providers**: Uses dedicated namespace `showcase-auth-providers` and release `rhdh-auth-providers` - **Logging**: Dedicated logs folder: `e2e-tests/auth-providers-logs` #### **Test Retry Configuration** + - **Other Showcase Projects**: Typically use 2 retries (CI default) - **showcase-auth-providers**: Configured with only 1 retry due to the complexity of authentication provider setup and teardown #### **Configuration and Deployment Approach** + - **Other Showcase Projects**: Use Bash scripts for configuration and deployment - **showcase-auth-providers**: Uses TypeScript for configuration and deployment management - **Configuration Files**: Uses different configuration files compared to other showcase projects: @@ -229,6 +269,7 @@ The `showcase-auth-providers` project has several significant differences from o - Dynamic RHDH instance management (create, update, restart, delete) #### **Required Plugins and Dependencies** + - **Other Showcase Projects**: Standard plugin dependencies - **showcase-auth-providers**: Requires specific plugins exported to `dynamic-plugins-root`: - `backstage-community-plugin-catalog-backend-module-keycloak-dynamic` @@ -237,6 +278,7 @@ The `showcase-auth-providers` project has several significant differences from o - `backstage-community-plugin-rbac` #### **Authentication Provider Coverage** + - **Other Showcase Projects**: Standard authentication testing - **showcase-auth-providers**: Tests multiple authentication providers: - OIDC using Red Hat Backstage Keycloak (RHBK) @@ -245,6 +287,7 @@ The `showcase-auth-providers` project has several significant differences from o - LDAP using Active Directory (commented out) #### **Test Structure and Files** + - **Other Showcase Projects**: Standard test file patterns - **showcase-auth-providers**: Dedicated test structure: - `e2e-tests/playwright/e2e/auth-providers/` directory @@ -276,12 +319,14 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### CI Job Types #### Pull Request Tests + - **Trigger**: Automatic for code changes, manual with `/ok-to-test` - **Environment**: Ephemeral OpenShift cluster - **Scope**: Both RBAC and non-RBAC namespaces - **Artifacts**: 6-month retention period #### Nightly Tests + - **Schedule**: Automated nightly runs - **Environments**: Multiple OCP versions, AKS, GKE - **Reporting**: Slack notifications to `#rhdh-e2e-alerts` @@ -289,14 +334,17 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### Test Execution Environment #### Local Development + Tests are run directly using Playwright Test with Node.js 22 and Yarn 3.8.7 as specified in the technology stack above. #### CI/CD Pipeline Execution + For CI/CD pipeline execution, tests run in a containerized environment using the image `.ci/images/Dockerfile`. This image is based on `mcr.microsoft.com/playwright` and uses Ubuntu as the base operating system. **Note**: Any additional system dependencies required for testing must be installed in this Docker image to ensure CI/CD pipeline compatibility. #### Alternative Execution Methods + **Podman Usage**: If you need to prepare the environment or run tests close to how CI/CD pipeline runs them, you can use Podman to run the `.ci/pipelines/openshift-ci-tests.sh` script inside the Docker image. **RHEL/Fedora Systems**: On Playwright unsupported systems such as RHEL or Fedora, running tests inside the containerized environment using Podman is the recommended approach to avoid compatibility issues. @@ -304,6 +352,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the ### Key CI Scripts #### Main Orchestration + - **`.ci/pipelines/openshift-ci-tests.sh`**: Main test orchestration script - **`.ci/pipelines/utils.sh`**: Utility functions - **`.ci/pipelines/reporting.sh`**: Reporting and notifications @@ -315,6 +364,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the The `.ci/package.json` file defines the CI infrastructure package configuration and development tools: **Available Scripts:** + ```bash # Code quality and linting yarn shellcheck # Shell script linting with severity warnings @@ -334,6 +384,7 @@ yarn prettier:fix # Fix formatting for shell, markdown, and YAML file - Functions may temporarily disable/re-enable error handling with `set +e` / `set -e` pattern #### Job Handlers + The main script dispatches to job-specific handlers in `.ci/pipelines/jobs/`: - `handle_aks_helm` / `handle_aks_operator`: AKS Helm/Operator deployment - `handle_eks_helm` / `handle_eks_operator`: EKS Helm/Operator deployment @@ -356,12 +407,15 @@ The `showcase-auth-providers` project has a unique deployment workflow that diff ### Access and Debugging #### Cluster Access + For cluster pool admins, use the login script: + ```bash .ci/pipelines/ocp-cluster-claim-login.sh ``` #### Debugging Process + 1. Run the login script 2. Provide Prow log URL when prompted 3. Script will forward cluster web console URL and credentials @@ -376,6 +430,7 @@ For cluster pool admins, use the login script: ### Debugging #### Local Debugging + ```bash # Set local development flags export ISRUNNINGLOCAL=true @@ -386,12 +441,14 @@ yarn playwright test --project showcase-auth-providers --workers 1 ``` #### CI Debugging + 1. **Access Logs**: Check PR artifacts or CI logs 2. **Cluster Access**: Use cluster claim login script 3. **Environment Variables**: Verify required variables 4. **Test Failures**: Review test reports and screenshots #### Common Debugging Tools + - **Playwright Inspector**: `yarn playwright test --debug` - **Trace Viewer**: `yarn playwright show-trace` - **Screenshots**: Automatic on failure @@ -402,9 +459,11 @@ yarn playwright test --project showcase-auth-providers --workers 1 ### System Dependencies #### macOS Requirements + **Important**: macOS users need to use GNU `grep` and GNU `sed` instead of the built-in BSD versions to avoid compatibility issues with scripts and CI/CD pipelines. Install using Homebrew: + ```bash brew install grep brew install gnu-sed @@ -417,6 +476,7 @@ brew install gnu-sed ### External Services #### Required Services + - **GitHub**: Authentication, repository access - **Keycloak**: Default authentication provider - **OpenShift**: Primary deployment platform @@ -425,6 +485,7 @@ brew install gnu-sed ### Internal Components #### Core Components + - **Backstage**: Main application framework - **Dynamic Plugins**: Plugin management system - **Catalog**: Entity management @@ -433,17 +494,20 @@ brew install gnu-sed ### Common Issues and Solutions #### Test Failures + 1. **Environment Issues**: Verify environment variables 2. **Authentication Problems**: Check credentials and tokens 3. **Resource Constraints**: Check cluster resources #### CI Failures + 1. **Cluster Issues**: Verify cluster availability 2. **Resource Limits**: Check resource quotas 3. **Network Problems**: Verify connectivity 4. **Configuration Errors**: Review job configuration #### Plugin Issues + 1. **Loading Failures**: Check plugin configuration 2. **Dependency Conflicts**: Verify package versions 3. **Configuration Errors**: Review plugin config @@ -452,12 +516,14 @@ brew install gnu-sed ## Documentation References ### Core Documentation + - [E2E Testing CI Documentation](docs/e2e-tests/CI.md) - [Dynamic Plugins Documentation](docs/dynamic-plugins/index.md) - [Authentication Providers README](e2e-tests/playwright/e2e/auth-providers/README.md) - [OpenShift CI Pipeline README](.ci/pipelines/README.md) ### Configuration Files + - [Playwright Project Names (Single Source of Truth)](e2e-tests/playwright/projects.json) - [Playwright Configuration](e2e-tests/playwright.config.ts) - [Package Configuration](e2e-tests/package.json) @@ -465,6 +531,7 @@ brew install gnu-sed - [CI Test Script](.ci/pipelines/openshift-ci-tests.sh) ### External Resources + - [OpenShift CI Documentation](https://docs.ci.openshift.org/) - [Playwright Documentation](https://playwright.dev/) - [Red Hat Developer Hub Documentation](https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/main/) @@ -472,6 +539,7 @@ brew install gnu-sed - [Dynamic Plugins Guide](https://github.com/backstage/backstage/tree/master/packages/backend-dynamic-feature-service) ### Key Scripts and Tools + - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) diff --git a/.claude/rules/e2e-fix-workflow.md b/.claude/rules/e2e-fix-workflow.md index f8b1c38d80..e2655f3ce9 100644 --- a/.claude/rules/e2e-fix-workflow.md +++ b/.claude/rules/e2e-fix-workflow.md @@ -76,4 +76,4 @@ fi All test code must follow the project's coding rules: - **`playwright-locators`** — locator priority, anti-patterns, assertions, Page Objects -- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts +- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts, and **verifying affected jobs with `/test ?` / `/test ` on the PR** diff --git a/.cursor/rules/ci-e2e-testing.mdc b/.cursor/rules/ci-e2e-testing.mdc index f25c163513..bf344827b4 100644 --- a/.cursor/rules/ci-e2e-testing.mdc +++ b/.cursor/rules/ci-e2e-testing.mdc @@ -8,6 +8,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## Table of Contents - [E2E Testing Framework](#e2e-testing-framework) +- [Verifying E2E Changes on a PR (`/test`)](#verifying-e2e-changes-on-a-pr-test) - [CI/CD Infrastructure](#cicd-infrastructure) - [Key Dependencies and Common Issues](#key-dependencies-and-common-issues) - [Documentation References](#documentation-references) @@ -15,6 +16,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## E2E Testing Framework ### Technology Stack + - **Testing Framework**: Playwright with TypeScript - **Node.js Version**: 22 - **Package Manager**: Yarn 3.8.7 @@ -26,6 +28,7 @@ Current versions for e2e-tests are defined in `e2e-tests/package.json` ### Test Structure and Organization #### Directory Structure + ```text e2e-tests/ ├── playwright/ @@ -189,6 +192,40 @@ yarn prettier:fix # Prettier fixing **Note**: The CI pipeline no longer uses yarn script aliases. Instead, it runs Playwright directly with `yarn playwright test --project=`. This decouples the namespace from the test project name, enabling more flexible namespace and test project reuse. +## Verifying E2E Changes on a PR (`/test`) + +**Required practice:** when a PR changes e2e tests or shared e2e infrastructure, trigger the Prow jobs that exercise those paths and wait for them to finish. Do not merge (or call the change verified) based only on unit/lint or the default mandatory PR check if the change affects other jobs. + +Nightlies and optional jobs are **not** all run automatically on every PR. Shared refactors (fixtures, `global-setup.ts`, `playwright.config.ts`, deploy helpers, auth harness, localization) can break jobs that never ran on the PR unless you trigger them. + +### How to trigger + +1. Comment `/test ?` on the PR — the `openshift-ci` bot lists available short job names for that branch/PR. +2. Comment `/test ` for each affected job (use **only** names from the bot list; never invent full `pull-ci-...` names). +3. Wait for the jobs to complete and confirm they still pass, or are not worse than before the change. + +```bash +gh pr comment --repo redhat-developer/rhdh --body "/test ?" +gh pr comment --repo redhat-developer/rhdh --body "/test e2e-ocp-helm-nightly" +``` + +- Prefer specific jobs over `/test all` (wastes cluster capacity). +- External contributors need `/ok-to-test` from an org member before `/test` works — see [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md). +- Detailed bot polling and job-name matching for fix PRs: `e2e-submit-and-review` skill. + +### What to trigger (by change area) + +| If you changed… | Trigger at least… | +|-----------------|-------------------| +| Shared fixtures, `global-setup.ts`, `wait-for-rhdh-ready`, browser/session helpers | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` | +| `showcase-runtime`, `runtime-deploy.ts`, `runtime-config.ts`, config-map / external-db / schema-mode specs | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` (runtime project) | +| `auth-providers/**`, auth harness / `rhdh-deployment` | `e2e-ocp-operator-auth-providers-nightly` | +| Localization specs, `locale.ts`, localization project config | `e2e-ocp-helm-localization-nightly` | +| Smoke / homepage / catalog / most showcase specs | Mandatory `e2e-ocp-helm` (auto on ready PRs); add nightly if the change is cross-cutting | +| K8s-only paths | Matching `e2e-aks-*` / `e2e-eks-*` / `e2e-gke-*` job from `/test ?` | + +Job → Playwright project mapping lives in the `e2e-fix-workflow` rule. Human-oriented CI trigger docs: [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md) and [docs/e2e-tests/CI-medic-guide.md](docs/e2e-tests/CI-medic-guide.md). + ### Environment Variables All the important environment variables are sourced in `.ci/pipelines/env_variables.sh` @@ -214,15 +251,18 @@ Playwright configuration (`e2e-tests/playwright.config.ts`): The `showcase-auth-providers` project has several significant differences from other showcase projects, particularly in deployment and configuration: #### **Namespace and Release Management** + - **Other Showcase Projects**: Use standard namespace patterns (`showcase-ci-nightly`, `showcase-rbac-nightly`) - **showcase-auth-providers**: Uses dedicated namespace `showcase-auth-providers` and release `rhdh-auth-providers` - **Logging**: Dedicated logs folder: `e2e-tests/auth-providers-logs` #### **Test Retry Configuration** + - **Other Showcase Projects**: Typically use 2 retries (CI default) - **showcase-auth-providers**: Configured with only 1 retry due to the complexity of authentication provider setup and teardown #### **Configuration and Deployment Approach** + - **Other Showcase Projects**: Use Bash scripts for configuration and deployment - **showcase-auth-providers**: Uses TypeScript for configuration and deployment management - **Configuration Files**: Uses different configuration files compared to other showcase projects: @@ -232,6 +272,7 @@ The `showcase-auth-providers` project has several significant differences from o - Dynamic RHDH instance management (create, update, restart, delete) #### **Required Plugins and Dependencies** + - **Other Showcase Projects**: Standard plugin dependencies - **showcase-auth-providers**: Requires specific plugins exported to `dynamic-plugins-root`: - `backstage-community-plugin-catalog-backend-module-keycloak-dynamic` @@ -240,6 +281,7 @@ The `showcase-auth-providers` project has several significant differences from o - `backstage-community-plugin-rbac` #### **Authentication Provider Coverage** + - **Other Showcase Projects**: Standard authentication testing - **showcase-auth-providers**: Tests multiple authentication providers: - OIDC using Red Hat Backstage Keycloak (RHBK) @@ -248,6 +290,7 @@ The `showcase-auth-providers` project has several significant differences from o - LDAP using Active Directory (commented out) #### **Test Structure and Files** + - **Other Showcase Projects**: Standard test file patterns - **showcase-auth-providers**: Dedicated test structure: - `e2e-tests/playwright/e2e/auth-providers/` directory @@ -279,12 +322,14 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### CI Job Types #### Pull Request Tests + - **Trigger**: Automatic for code changes, manual with `/ok-to-test` - **Environment**: Ephemeral OpenShift cluster - **Scope**: Both RBAC and non-RBAC namespaces - **Artifacts**: 6-month retention period #### Nightly Tests + - **Schedule**: Automated nightly runs - **Environments**: Multiple OCP versions, AKS, GKE - **Reporting**: Slack notifications to `#rhdh-e2e-alerts` @@ -292,14 +337,17 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### Test Execution Environment #### Local Development + Tests are run directly using Playwright Test with Node.js 22 and Yarn 3.8.7 as specified in the technology stack above. #### CI/CD Pipeline Execution + For CI/CD pipeline execution, tests run in a containerized environment using the image `.ci/images/Dockerfile`. This image is based on `mcr.microsoft.com/playwright` and uses Ubuntu as the base operating system. **Note**: Any additional system dependencies required for testing must be installed in this Docker image to ensure CI/CD pipeline compatibility. #### Alternative Execution Methods + **Podman Usage**: If you need to prepare the environment or run tests close to how CI/CD pipeline runs them, you can use Podman to run the `.ci/pipelines/openshift-ci-tests.sh` script inside the Docker image. **RHEL/Fedora Systems**: On Playwright unsupported systems such as RHEL or Fedora, running tests inside the containerized environment using Podman is the recommended approach to avoid compatibility issues. @@ -307,6 +355,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the ### Key CI Scripts #### Main Orchestration + - **`.ci/pipelines/openshift-ci-tests.sh`**: Main test orchestration script - **`.ci/pipelines/utils.sh`**: Utility functions - **`.ci/pipelines/reporting.sh`**: Reporting and notifications @@ -318,6 +367,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the The `.ci/package.json` file defines the CI infrastructure package configuration and development tools: **Available Scripts:** + ```bash # Code quality and linting yarn shellcheck # Shell script linting with severity warnings @@ -337,6 +387,7 @@ yarn prettier:fix # Fix formatting for shell, markdown, and YAML file - Functions may temporarily disable/re-enable error handling with `set +e` / `set -e` pattern #### Job Handlers + The main script dispatches to job-specific handlers in `.ci/pipelines/jobs/`: - `handle_aks_helm` / `handle_aks_operator`: AKS Helm/Operator deployment - `handle_eks_helm` / `handle_eks_operator`: EKS Helm/Operator deployment @@ -359,12 +410,15 @@ The `showcase-auth-providers` project has a unique deployment workflow that diff ### Access and Debugging #### Cluster Access + For cluster pool admins, use the login script: + ```bash .ci/pipelines/ocp-cluster-claim-login.sh ``` #### Debugging Process + 1. Run the login script 2. Provide Prow log URL when prompted 3. Script will forward cluster web console URL and credentials @@ -379,6 +433,7 @@ For cluster pool admins, use the login script: ### Debugging #### Local Debugging + ```bash # Set local development flags export ISRUNNINGLOCAL=true @@ -389,12 +444,14 @@ yarn playwright test --project showcase-auth-providers --workers 1 ``` #### CI Debugging + 1. **Access Logs**: Check PR artifacts or CI logs 2. **Cluster Access**: Use cluster claim login script 3. **Environment Variables**: Verify required variables 4. **Test Failures**: Review test reports and screenshots #### Common Debugging Tools + - **Playwright Inspector**: `yarn playwright test --debug` - **Trace Viewer**: `yarn playwright show-trace` - **Screenshots**: Automatic on failure @@ -405,9 +462,11 @@ yarn playwright test --project showcase-auth-providers --workers 1 ### System Dependencies #### macOS Requirements + **Important**: macOS users need to use GNU `grep` and GNU `sed` instead of the built-in BSD versions to avoid compatibility issues with scripts and CI/CD pipelines. Install using Homebrew: + ```bash brew install grep brew install gnu-sed @@ -420,6 +479,7 @@ brew install gnu-sed ### External Services #### Required Services + - **GitHub**: Authentication, repository access - **Keycloak**: Default authentication provider - **OpenShift**: Primary deployment platform @@ -428,6 +488,7 @@ brew install gnu-sed ### Internal Components #### Core Components + - **Backstage**: Main application framework - **Dynamic Plugins**: Plugin management system - **Catalog**: Entity management @@ -436,17 +497,20 @@ brew install gnu-sed ### Common Issues and Solutions #### Test Failures + 1. **Environment Issues**: Verify environment variables 2. **Authentication Problems**: Check credentials and tokens 3. **Resource Constraints**: Check cluster resources #### CI Failures + 1. **Cluster Issues**: Verify cluster availability 2. **Resource Limits**: Check resource quotas 3. **Network Problems**: Verify connectivity 4. **Configuration Errors**: Review job configuration #### Plugin Issues + 1. **Loading Failures**: Check plugin configuration 2. **Dependency Conflicts**: Verify package versions 3. **Configuration Errors**: Review plugin config @@ -455,12 +519,14 @@ brew install gnu-sed ## Documentation References ### Core Documentation + - [E2E Testing CI Documentation](docs/e2e-tests/CI.md) - [Dynamic Plugins Documentation](docs/dynamic-plugins/index.md) - [Authentication Providers README](e2e-tests/playwright/e2e/auth-providers/README.md) - [OpenShift CI Pipeline README](.ci/pipelines/README.md) ### Configuration Files + - [Playwright Project Names (Single Source of Truth)](e2e-tests/playwright/projects.json) - [Playwright Configuration](e2e-tests/playwright.config.ts) - [Package Configuration](e2e-tests/package.json) @@ -468,6 +534,7 @@ brew install gnu-sed - [CI Test Script](.ci/pipelines/openshift-ci-tests.sh) ### External Resources + - [OpenShift CI Documentation](https://docs.ci.openshift.org/) - [Playwright Documentation](https://playwright.dev/) - [Red Hat Developer Hub Documentation](https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/main/) @@ -475,6 +542,7 @@ brew install gnu-sed - [Dynamic Plugins Guide](https://github.com/backstage/backstage/tree/master/packages/backend-dynamic-feature-service) ### Key Scripts and Tools + - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) diff --git a/.cursor/rules/e2e-fix-workflow.mdc b/.cursor/rules/e2e-fix-workflow.mdc index b63e222a9d..5a1e039e5c 100644 --- a/.cursor/rules/e2e-fix-workflow.mdc +++ b/.cursor/rules/e2e-fix-workflow.mdc @@ -79,4 +79,4 @@ fi All test code must follow the project's coding rules: - **`playwright-locators`** — locator priority, anti-patterns, assertions, Page Objects -- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts +- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts, and **verifying affected jobs with `/test ?` / `/test ` on the PR** diff --git a/.opencode/memories/ci-e2e-testing.md b/.opencode/memories/ci-e2e-testing.md index 2c54766ef6..52fcfaa020 100644 --- a/.opencode/memories/ci-e2e-testing.md +++ b/.opencode/memories/ci-e2e-testing.md @@ -5,6 +5,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## Table of Contents - [E2E Testing Framework](#e2e-testing-framework) +- [Verifying E2E Changes on a PR (`/test`)](#verifying-e2e-changes-on-a-pr-test) - [CI/CD Infrastructure](#cicd-infrastructure) - [Key Dependencies and Common Issues](#key-dependencies-and-common-issues) - [Documentation References](#documentation-references) @@ -12,6 +13,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## E2E Testing Framework ### Technology Stack + - **Testing Framework**: Playwright with TypeScript - **Node.js Version**: 22 - **Package Manager**: Yarn 3.8.7 @@ -23,6 +25,7 @@ Current versions for e2e-tests are defined in `e2e-tests/package.json` ### Test Structure and Organization #### Directory Structure + ```text e2e-tests/ ├── playwright/ @@ -186,6 +189,40 @@ yarn prettier:fix # Prettier fixing **Note**: The CI pipeline no longer uses yarn script aliases. Instead, it runs Playwright directly with `yarn playwright test --project=`. This decouples the namespace from the test project name, enabling more flexible namespace and test project reuse. +## Verifying E2E Changes on a PR (`/test`) + +**Required practice:** when a PR changes e2e tests or shared e2e infrastructure, trigger the Prow jobs that exercise those paths and wait for them to finish. Do not merge (or call the change verified) based only on unit/lint or the default mandatory PR check if the change affects other jobs. + +Nightlies and optional jobs are **not** all run automatically on every PR. Shared refactors (fixtures, `global-setup.ts`, `playwright.config.ts`, deploy helpers, auth harness, localization) can break jobs that never ran on the PR unless you trigger them. + +### How to trigger + +1. Comment `/test ?` on the PR — the `openshift-ci` bot lists available short job names for that branch/PR. +2. Comment `/test ` for each affected job (use **only** names from the bot list; never invent full `pull-ci-...` names). +3. Wait for the jobs to complete and confirm they still pass, or are not worse than before the change. + +```bash +gh pr comment --repo redhat-developer/rhdh --body "/test ?" +gh pr comment --repo redhat-developer/rhdh --body "/test e2e-ocp-helm-nightly" +``` + +- Prefer specific jobs over `/test all` (wastes cluster capacity). +- External contributors need `/ok-to-test` from an org member before `/test` works — see [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md). +- Detailed bot polling and job-name matching for fix PRs: `e2e-submit-and-review` skill. + +### What to trigger (by change area) + +| If you changed… | Trigger at least… | +|-----------------|-------------------| +| Shared fixtures, `global-setup.ts`, `wait-for-rhdh-ready`, browser/session helpers | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` | +| `showcase-runtime`, `runtime-deploy.ts`, `runtime-config.ts`, config-map / external-db / schema-mode specs | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` (runtime project) | +| `auth-providers/**`, auth harness / `rhdh-deployment` | `e2e-ocp-operator-auth-providers-nightly` | +| Localization specs, `locale.ts`, localization project config | `e2e-ocp-helm-localization-nightly` | +| Smoke / homepage / catalog / most showcase specs | Mandatory `e2e-ocp-helm` (auto on ready PRs); add nightly if the change is cross-cutting | +| K8s-only paths | Matching `e2e-aks-*` / `e2e-eks-*` / `e2e-gke-*` job from `/test ?` | + +Job → Playwright project mapping lives in the `e2e-fix-workflow` rule. Human-oriented CI trigger docs: [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md) and [docs/e2e-tests/CI-medic-guide.md](docs/e2e-tests/CI-medic-guide.md). + ### Environment Variables All the important environment variables are sourced in `.ci/pipelines/env_variables.sh` @@ -211,15 +248,18 @@ Playwright configuration (`e2e-tests/playwright.config.ts`): The `showcase-auth-providers` project has several significant differences from other showcase projects, particularly in deployment and configuration: #### **Namespace and Release Management** + - **Other Showcase Projects**: Use standard namespace patterns (`showcase-ci-nightly`, `showcase-rbac-nightly`) - **showcase-auth-providers**: Uses dedicated namespace `showcase-auth-providers` and release `rhdh-auth-providers` - **Logging**: Dedicated logs folder: `e2e-tests/auth-providers-logs` #### **Test Retry Configuration** + - **Other Showcase Projects**: Typically use 2 retries (CI default) - **showcase-auth-providers**: Configured with only 1 retry due to the complexity of authentication provider setup and teardown #### **Configuration and Deployment Approach** + - **Other Showcase Projects**: Use Bash scripts for configuration and deployment - **showcase-auth-providers**: Uses TypeScript for configuration and deployment management - **Configuration Files**: Uses different configuration files compared to other showcase projects: @@ -229,6 +269,7 @@ The `showcase-auth-providers` project has several significant differences from o - Dynamic RHDH instance management (create, update, restart, delete) #### **Required Plugins and Dependencies** + - **Other Showcase Projects**: Standard plugin dependencies - **showcase-auth-providers**: Requires specific plugins exported to `dynamic-plugins-root`: - `backstage-community-plugin-catalog-backend-module-keycloak-dynamic` @@ -237,6 +278,7 @@ The `showcase-auth-providers` project has several significant differences from o - `backstage-community-plugin-rbac` #### **Authentication Provider Coverage** + - **Other Showcase Projects**: Standard authentication testing - **showcase-auth-providers**: Tests multiple authentication providers: - OIDC using Red Hat Backstage Keycloak (RHBK) @@ -245,6 +287,7 @@ The `showcase-auth-providers` project has several significant differences from o - LDAP using Active Directory (commented out) #### **Test Structure and Files** + - **Other Showcase Projects**: Standard test file patterns - **showcase-auth-providers**: Dedicated test structure: - `e2e-tests/playwright/e2e/auth-providers/` directory @@ -276,12 +319,14 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### CI Job Types #### Pull Request Tests + - **Trigger**: Automatic for code changes, manual with `/ok-to-test` - **Environment**: Ephemeral OpenShift cluster - **Scope**: Both RBAC and non-RBAC namespaces - **Artifacts**: 6-month retention period #### Nightly Tests + - **Schedule**: Automated nightly runs - **Environments**: Multiple OCP versions, AKS, GKE - **Reporting**: Slack notifications to `#rhdh-e2e-alerts` @@ -289,14 +334,17 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### Test Execution Environment #### Local Development + Tests are run directly using Playwright Test with Node.js 22 and Yarn 3.8.7 as specified in the technology stack above. #### CI/CD Pipeline Execution + For CI/CD pipeline execution, tests run in a containerized environment using the image `.ci/images/Dockerfile`. This image is based on `mcr.microsoft.com/playwright` and uses Ubuntu as the base operating system. **Note**: Any additional system dependencies required for testing must be installed in this Docker image to ensure CI/CD pipeline compatibility. #### Alternative Execution Methods + **Podman Usage**: If you need to prepare the environment or run tests close to how CI/CD pipeline runs them, you can use Podman to run the `.ci/pipelines/openshift-ci-tests.sh` script inside the Docker image. **RHEL/Fedora Systems**: On Playwright unsupported systems such as RHEL or Fedora, running tests inside the containerized environment using Podman is the recommended approach to avoid compatibility issues. @@ -304,6 +352,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the ### Key CI Scripts #### Main Orchestration + - **`.ci/pipelines/openshift-ci-tests.sh`**: Main test orchestration script - **`.ci/pipelines/utils.sh`**: Utility functions - **`.ci/pipelines/reporting.sh`**: Reporting and notifications @@ -315,6 +364,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the The `.ci/package.json` file defines the CI infrastructure package configuration and development tools: **Available Scripts:** + ```bash # Code quality and linting yarn shellcheck # Shell script linting with severity warnings @@ -334,6 +384,7 @@ yarn prettier:fix # Fix formatting for shell, markdown, and YAML file - Functions may temporarily disable/re-enable error handling with `set +e` / `set -e` pattern #### Job Handlers + The main script dispatches to job-specific handlers in `.ci/pipelines/jobs/`: - `handle_aks_helm` / `handle_aks_operator`: AKS Helm/Operator deployment - `handle_eks_helm` / `handle_eks_operator`: EKS Helm/Operator deployment @@ -356,12 +407,15 @@ The `showcase-auth-providers` project has a unique deployment workflow that diff ### Access and Debugging #### Cluster Access + For cluster pool admins, use the login script: + ```bash .ci/pipelines/ocp-cluster-claim-login.sh ``` #### Debugging Process + 1. Run the login script 2. Provide Prow log URL when prompted 3. Script will forward cluster web console URL and credentials @@ -376,6 +430,7 @@ For cluster pool admins, use the login script: ### Debugging #### Local Debugging + ```bash # Set local development flags export ISRUNNINGLOCAL=true @@ -386,12 +441,14 @@ yarn playwright test --project showcase-auth-providers --workers 1 ``` #### CI Debugging + 1. **Access Logs**: Check PR artifacts or CI logs 2. **Cluster Access**: Use cluster claim login script 3. **Environment Variables**: Verify required variables 4. **Test Failures**: Review test reports and screenshots #### Common Debugging Tools + - **Playwright Inspector**: `yarn playwright test --debug` - **Trace Viewer**: `yarn playwright show-trace` - **Screenshots**: Automatic on failure @@ -402,9 +459,11 @@ yarn playwright test --project showcase-auth-providers --workers 1 ### System Dependencies #### macOS Requirements + **Important**: macOS users need to use GNU `grep` and GNU `sed` instead of the built-in BSD versions to avoid compatibility issues with scripts and CI/CD pipelines. Install using Homebrew: + ```bash brew install grep brew install gnu-sed @@ -417,6 +476,7 @@ brew install gnu-sed ### External Services #### Required Services + - **GitHub**: Authentication, repository access - **Keycloak**: Default authentication provider - **OpenShift**: Primary deployment platform @@ -425,6 +485,7 @@ brew install gnu-sed ### Internal Components #### Core Components + - **Backstage**: Main application framework - **Dynamic Plugins**: Plugin management system - **Catalog**: Entity management @@ -433,17 +494,20 @@ brew install gnu-sed ### Common Issues and Solutions #### Test Failures + 1. **Environment Issues**: Verify environment variables 2. **Authentication Problems**: Check credentials and tokens 3. **Resource Constraints**: Check cluster resources #### CI Failures + 1. **Cluster Issues**: Verify cluster availability 2. **Resource Limits**: Check resource quotas 3. **Network Problems**: Verify connectivity 4. **Configuration Errors**: Review job configuration #### Plugin Issues + 1. **Loading Failures**: Check plugin configuration 2. **Dependency Conflicts**: Verify package versions 3. **Configuration Errors**: Review plugin config @@ -452,12 +516,14 @@ brew install gnu-sed ## Documentation References ### Core Documentation + - [E2E Testing CI Documentation](docs/e2e-tests/CI.md) - [Dynamic Plugins Documentation](docs/dynamic-plugins/index.md) - [Authentication Providers README](e2e-tests/playwright/e2e/auth-providers/README.md) - [OpenShift CI Pipeline README](.ci/pipelines/README.md) ### Configuration Files + - [Playwright Project Names (Single Source of Truth)](e2e-tests/playwright/projects.json) - [Playwright Configuration](e2e-tests/playwright.config.ts) - [Package Configuration](e2e-tests/package.json) @@ -465,6 +531,7 @@ brew install gnu-sed - [CI Test Script](.ci/pipelines/openshift-ci-tests.sh) ### External Resources + - [OpenShift CI Documentation](https://docs.ci.openshift.org/) - [Playwright Documentation](https://playwright.dev/) - [Red Hat Developer Hub Documentation](https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/main/) @@ -472,6 +539,7 @@ brew install gnu-sed - [Dynamic Plugins Guide](https://github.com/backstage/backstage/tree/master/packages/backend-dynamic-feature-service) ### Key Scripts and Tools + - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) diff --git a/.opencode/memories/e2e-fix-workflow.md b/.opencode/memories/e2e-fix-workflow.md index f8b1c38d80..e2655f3ce9 100644 --- a/.opencode/memories/e2e-fix-workflow.md +++ b/.opencode/memories/e2e-fix-workflow.md @@ -76,4 +76,4 @@ fi All test code must follow the project's coding rules: - **`playwright-locators`** — locator priority, anti-patterns, assertions, Page Objects -- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts +- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts, and **verifying affected jobs with `/test ?` / `/test ` on the PR** diff --git a/.rulesync/rules/ci-e2e-testing.md b/.rulesync/rules/ci-e2e-testing.md index c5ddfa6c19..21cf9604f0 100644 --- a/.rulesync/rules/ci-e2e-testing.md +++ b/.rulesync/rules/ci-e2e-testing.md @@ -11,6 +11,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## Table of Contents - [E2E Testing Framework](#e2e-testing-framework) +- [Verifying E2E Changes on a PR (`/test`)](#verifying-e2e-changes-on-a-pr-test) - [CI/CD Infrastructure](#cicd-infrastructure) - [Key Dependencies and Common Issues](#key-dependencies-and-common-issues) - [Documentation References](#documentation-references) @@ -18,6 +19,7 @@ This document serves as a comprehensive starting point for LLMs working with the ## E2E Testing Framework ### Technology Stack + - **Testing Framework**: Playwright with TypeScript - **Node.js Version**: 22 - **Package Manager**: Yarn 3.8.7 @@ -29,6 +31,7 @@ Current versions for e2e-tests are defined in `e2e-tests/package.json` ### Test Structure and Organization #### Directory Structure + ```text e2e-tests/ ├── playwright/ @@ -192,6 +195,40 @@ yarn prettier:fix # Prettier fixing **Note**: The CI pipeline no longer uses yarn script aliases. Instead, it runs Playwright directly with `yarn playwright test --project=`. This decouples the namespace from the test project name, enabling more flexible namespace and test project reuse. +## Verifying E2E Changes on a PR (`/test`) + +**Required practice:** when a PR changes e2e tests or shared e2e infrastructure, trigger the Prow jobs that exercise those paths and wait for them to finish. Do not merge (or call the change verified) based only on unit/lint or the default mandatory PR check if the change affects other jobs. + +Nightlies and optional jobs are **not** all run automatically on every PR. Shared refactors (fixtures, `global-setup.ts`, `playwright.config.ts`, deploy helpers, auth harness, localization) can break jobs that never ran on the PR unless you trigger them. + +### How to trigger + +1. Comment `/test ?` on the PR — the `openshift-ci` bot lists available short job names for that branch/PR. +2. Comment `/test ` for each affected job (use **only** names from the bot list; never invent full `pull-ci-...` names). +3. Wait for the jobs to complete and confirm they still pass, or are not worse than before the change. + +```bash +gh pr comment --repo redhat-developer/rhdh --body "/test ?" +gh pr comment --repo redhat-developer/rhdh --body "/test e2e-ocp-helm-nightly" +``` + +- Prefer specific jobs over `/test all` (wastes cluster capacity). +- External contributors need `/ok-to-test` from an org member before `/test` works — see [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md). +- Detailed bot polling and job-name matching for fix PRs: `e2e-submit-and-review` skill. + +### What to trigger (by change area) + +| If you changed… | Trigger at least… | +|-----------------|-------------------| +| Shared fixtures, `global-setup.ts`, `wait-for-rhdh-ready`, browser/session helpers | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` | +| `showcase-runtime`, `runtime-deploy.ts`, `runtime-config.ts`, config-map / external-db / schema-mode specs | `e2e-ocp-helm-nightly` and `e2e-ocp-operator-nightly` (runtime project) | +| `auth-providers/**`, auth harness / `rhdh-deployment` | `e2e-ocp-operator-auth-providers-nightly` | +| Localization specs, `locale.ts`, localization project config | `e2e-ocp-helm-localization-nightly` | +| Smoke / homepage / catalog / most showcase specs | Mandatory `e2e-ocp-helm` (auto on ready PRs); add nightly if the change is cross-cutting | +| K8s-only paths | Matching `e2e-aks-*` / `e2e-eks-*` / `e2e-gke-*` job from `/test ?` | + +Job → Playwright project mapping lives in the `e2e-fix-workflow` rule. Human-oriented CI trigger docs: [docs/e2e-tests/CI.md](docs/e2e-tests/CI.md) and [docs/e2e-tests/CI-medic-guide.md](docs/e2e-tests/CI-medic-guide.md). + ### Environment Variables All the important environment variables are sourced in `.ci/pipelines/env_variables.sh` @@ -217,15 +254,18 @@ Playwright configuration (`e2e-tests/playwright.config.ts`): The `showcase-auth-providers` project has several significant differences from other showcase projects, particularly in deployment and configuration: #### **Namespace and Release Management** + - **Other Showcase Projects**: Use standard namespace patterns (`showcase-ci-nightly`, `showcase-rbac-nightly`) - **showcase-auth-providers**: Uses dedicated namespace `showcase-auth-providers` and release `rhdh-auth-providers` - **Logging**: Dedicated logs folder: `e2e-tests/auth-providers-logs` #### **Test Retry Configuration** + - **Other Showcase Projects**: Typically use 2 retries (CI default) - **showcase-auth-providers**: Configured with only 1 retry due to the complexity of authentication provider setup and teardown #### **Configuration and Deployment Approach** + - **Other Showcase Projects**: Use Bash scripts for configuration and deployment - **showcase-auth-providers**: Uses TypeScript for configuration and deployment management - **Configuration Files**: Uses different configuration files compared to other showcase projects: @@ -235,6 +275,7 @@ The `showcase-auth-providers` project has several significant differences from o - Dynamic RHDH instance management (create, update, restart, delete) #### **Required Plugins and Dependencies** + - **Other Showcase Projects**: Standard plugin dependencies - **showcase-auth-providers**: Requires specific plugins exported to `dynamic-plugins-root`: - `backstage-community-plugin-catalog-backend-module-keycloak-dynamic` @@ -243,6 +284,7 @@ The `showcase-auth-providers` project has several significant differences from o - `backstage-community-plugin-rbac` #### **Authentication Provider Coverage** + - **Other Showcase Projects**: Standard authentication testing - **showcase-auth-providers**: Tests multiple authentication providers: - OIDC using Red Hat Backstage Keycloak (RHBK) @@ -251,6 +293,7 @@ The `showcase-auth-providers` project has several significant differences from o - LDAP using Active Directory (commented out) #### **Test Structure and Files** + - **Other Showcase Projects**: Standard test file patterns - **showcase-auth-providers**: Dedicated test structure: - `e2e-tests/playwright/e2e/auth-providers/` directory @@ -282,12 +325,14 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### CI Job Types #### Pull Request Tests + - **Trigger**: Automatic for code changes, manual with `/ok-to-test` - **Environment**: Ephemeral OpenShift cluster - **Scope**: Both RBAC and non-RBAC namespaces - **Artifacts**: 6-month retention period #### Nightly Tests + - **Schedule**: Automated nightly runs - **Environments**: Multiple OCP versions, AKS, GKE - **Reporting**: Slack notifications to `#rhdh-e2e-alerts` @@ -295,14 +340,17 @@ RHDH uses dedicated Hive cluster pools with the `rhdh` prefix on AWS `us-east-2` ### Test Execution Environment #### Local Development + Tests are run directly using Playwright Test with Node.js 22 and Yarn 3.8.7 as specified in the technology stack above. #### CI/CD Pipeline Execution + For CI/CD pipeline execution, tests run in a containerized environment using the image `.ci/images/Dockerfile`. This image is based on `mcr.microsoft.com/playwright` and uses Ubuntu as the base operating system. **Note**: Any additional system dependencies required for testing must be installed in this Docker image to ensure CI/CD pipeline compatibility. #### Alternative Execution Methods + **Podman Usage**: If you need to prepare the environment or run tests close to how CI/CD pipeline runs them, you can use Podman to run the `.ci/pipelines/openshift-ci-tests.sh` script inside the Docker image. **RHEL/Fedora Systems**: On Playwright unsupported systems such as RHEL or Fedora, running tests inside the containerized environment using Podman is the recommended approach to avoid compatibility issues. @@ -310,6 +358,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the ### Key CI Scripts #### Main Orchestration + - **`.ci/pipelines/openshift-ci-tests.sh`**: Main test orchestration script - **`.ci/pipelines/utils.sh`**: Utility functions - **`.ci/pipelines/reporting.sh`**: Reporting and notifications @@ -321,6 +370,7 @@ For CI/CD pipeline execution, tests run in a containerized environment using the The `.ci/package.json` file defines the CI infrastructure package configuration and development tools: **Available Scripts:** + ```bash # Code quality and linting yarn shellcheck # Shell script linting with severity warnings @@ -340,6 +390,7 @@ yarn prettier:fix # Fix formatting for shell, markdown, and YAML file - Functions may temporarily disable/re-enable error handling with `set +e` / `set -e` pattern #### Job Handlers + The main script dispatches to job-specific handlers in `.ci/pipelines/jobs/`: - `handle_aks_helm` / `handle_aks_operator`: AKS Helm/Operator deployment - `handle_eks_helm` / `handle_eks_operator`: EKS Helm/Operator deployment @@ -362,12 +413,15 @@ The `showcase-auth-providers` project has a unique deployment workflow that diff ### Access and Debugging #### Cluster Access + For cluster pool admins, use the login script: + ```bash .ci/pipelines/ocp-cluster-claim-login.sh ``` #### Debugging Process + 1. Run the login script 2. Provide Prow log URL when prompted 3. Script will forward cluster web console URL and credentials @@ -382,6 +436,7 @@ For cluster pool admins, use the login script: ### Debugging #### Local Debugging + ```bash # Set local development flags export ISRUNNINGLOCAL=true @@ -392,12 +447,14 @@ yarn playwright test --project showcase-auth-providers --workers 1 ``` #### CI Debugging + 1. **Access Logs**: Check PR artifacts or CI logs 2. **Cluster Access**: Use cluster claim login script 3. **Environment Variables**: Verify required variables 4. **Test Failures**: Review test reports and screenshots #### Common Debugging Tools + - **Playwright Inspector**: `yarn playwright test --debug` - **Trace Viewer**: `yarn playwright show-trace` - **Screenshots**: Automatic on failure @@ -408,9 +465,11 @@ yarn playwright test --project showcase-auth-providers --workers 1 ### System Dependencies #### macOS Requirements + **Important**: macOS users need to use GNU `grep` and GNU `sed` instead of the built-in BSD versions to avoid compatibility issues with scripts and CI/CD pipelines. Install using Homebrew: + ```bash brew install grep brew install gnu-sed @@ -423,6 +482,7 @@ brew install gnu-sed ### External Services #### Required Services + - **GitHub**: Authentication, repository access - **Keycloak**: Default authentication provider - **OpenShift**: Primary deployment platform @@ -431,6 +491,7 @@ brew install gnu-sed ### Internal Components #### Core Components + - **Backstage**: Main application framework - **Dynamic Plugins**: Plugin management system - **Catalog**: Entity management @@ -439,17 +500,20 @@ brew install gnu-sed ### Common Issues and Solutions #### Test Failures + 1. **Environment Issues**: Verify environment variables 2. **Authentication Problems**: Check credentials and tokens 3. **Resource Constraints**: Check cluster resources #### CI Failures + 1. **Cluster Issues**: Verify cluster availability 2. **Resource Limits**: Check resource quotas 3. **Network Problems**: Verify connectivity 4. **Configuration Errors**: Review job configuration #### Plugin Issues + 1. **Loading Failures**: Check plugin configuration 2. **Dependency Conflicts**: Verify package versions 3. **Configuration Errors**: Review plugin config @@ -458,12 +522,14 @@ brew install gnu-sed ## Documentation References ### Core Documentation + - [E2E Testing CI Documentation](docs/e2e-tests/CI.md) - [Dynamic Plugins Documentation](docs/dynamic-plugins/index.md) - [Authentication Providers README](e2e-tests/playwright/e2e/auth-providers/README.md) - [OpenShift CI Pipeline README](.ci/pipelines/README.md) ### Configuration Files + - [Playwright Project Names (Single Source of Truth)](e2e-tests/playwright/projects.json) - [Playwright Configuration](e2e-tests/playwright.config.ts) - [Package Configuration](e2e-tests/package.json) @@ -471,6 +537,7 @@ brew install gnu-sed - [CI Test Script](.ci/pipelines/openshift-ci-tests.sh) ### External Resources + - [OpenShift CI Documentation](https://docs.ci.openshift.org/) - [Playwright Documentation](https://playwright.dev/) - [Red Hat Developer Hub Documentation](https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/main/) @@ -478,6 +545,7 @@ brew install gnu-sed - [Dynamic Plugins Guide](https://github.com/backstage/backstage/tree/master/packages/backend-dynamic-feature-service) ### Key Scripts and Tools + - [Cluster Login Script](.ci/pipelines/ocp-cluster-claim-login.sh) - [Test Reporting Script](.ci/pipelines/reporting.sh) - [Environment Variables](.ci/pipelines/env_variables.sh) diff --git a/.rulesync/rules/e2e-fix-workflow.md b/.rulesync/rules/e2e-fix-workflow.md index 48dd5416a8..73a4f10826 100644 --- a/.rulesync/rules/e2e-fix-workflow.md +++ b/.rulesync/rules/e2e-fix-workflow.md @@ -82,4 +82,4 @@ fi All test code must follow the project's coding rules: - **`playwright-locators`** — locator priority, anti-patterns, assertions, Page Objects -- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts +- **`ci-e2e-testing`** — test structure, component annotations, utility classes, CI scripts, and **verifying affected jobs with `/test ?` / `/test ` on the PR** diff --git a/docs/e2e-tests/CI-medic-guide.md b/docs/e2e-tests/CI-medic-guide.md index d038b0caf1..91c29cfe79 100644 --- a/docs/e2e-tests/CI-medic-guide.md +++ b/docs/e2e-tests/CI-medic-guide.md @@ -46,13 +46,13 @@ Each alert includes links to the job logs, artifacts, and a summary of which dep ### Two Types of CI Jobs -| | Nightly (Periodic) Jobs | PR Check (Presubmit) Jobs | -|---|---|---| -| **Trigger** | Scheduled (usually once per night) | On PR creation/update, or `/ok-to-test` | -| **Scope** | Full suite: showcase, RBAC, runtime, sanity plugins, localization, auth providers | Smaller scope: showcase + RBAC only | -| **Platforms** | OCP (multiple versions), AKS, EKS, GKE, OSD-GCP | OCP only (single version) | -| **Install methods** | Helm and Operator | Helm only | -| **Alert channel** | `#rhdh-e2e-alerts` / `#rhdh-e2e-alerts-{version}` | PR status checks on GitHub | +| | Nightly (Periodic) Jobs | PR Check (Presubmit) Jobs | +| ------------------- | --------------------------------------------------------------------------------- | --------------------------------------- | +| **Trigger** | Scheduled (usually once per night) | On PR creation/update, or `/ok-to-test` | +| **Scope** | Full suite: showcase, RBAC, runtime, sanity plugins, localization, auth providers | Smaller scope: showcase + RBAC only | +| **Platforms** | OCP (multiple versions), AKS, EKS, GKE, OSD-GCP | OCP only (single version) | +| **Install methods** | Helm and Operator | Helm only | +| **Alert channel** | `#rhdh-e2e-alerts` / `#rhdh-e2e-alerts-{version}` | PR status checks on GitHub | **Triggering jobs on a PR**: All nightly job variants can also be triggered on a PR by commenting `/test `. Use `/test ?` to list all available jobs for that PR. This is useful for verifying a fix against a specific platform or install method before merging. @@ -77,19 +77,20 @@ That's enough to start triaging. Use the rest of the guide on demand as you encounter specific situations: -| Situation | Section to consult | -|-----------|-------------------| -| A job failed and you need to find the logs | [Where to Find Logs and Artifacts](#where-to-find-logs-and-artifacts) | -| You can't tell *where* in the pipeline it broke | [Job Lifecycle and Failure Points](#job-lifecycle-and-failure-points) | -| You need to understand what a specific job does | [Job Types Reference](#job-types-reference) | -| You're unsure if it's infra, deployment, or a test bug | [Identifying Failure Types](#identifying-failure-types) | -| You need to re-trigger a job or access a cluster | [Useful Links and Tools](#useful-links-and-tools) | +| Situation | Section to consult | +| ------------------------------------------------------ | --------------------------------------------------------------------- | +| A job failed and you need to find the logs | [Where to Find Logs and Artifacts](#where-to-find-logs-and-artifacts) | +| You can't tell _where_ in the pipeline it broke | [Job Lifecycle and Failure Points](#job-lifecycle-and-failure-points) | +| You need to understand what a specific job does | [Job Types Reference](#job-types-reference) | +| You're unsure if it's infra, deployment, or a test bug | [Identifying Failure Types](#identifying-failure-types) | +| You need to re-trigger a job or access a cluster | [Useful Links and Tools](#useful-links-and-tools) | ### Understanding the CI Scripts The guide links heavily to scripts in `.ci/pipelines/`. You don't need to read those scripts upfront either. When you're investigating a failure and need to understand what a specific phase does, follow the links from the relevant [Job Lifecycle](#job-lifecycle-and-failure-points) or [Job Types](#job-types-reference) section to the source code. Key entry points if you do want to explore: + - [`.ci/pipelines/openshift-ci-tests.sh`](../../.ci/pipelines/openshift-ci-tests.sh) -- the main dispatcher, start here to understand how jobs are routed - [`.ci/pipelines/jobs/`](../../.ci/pipelines/jobs/) -- one handler per job type, each is self-contained - [`.ci/pipelines/lib/testing.sh`](../../.ci/pipelines/lib/testing.sh) -- how tests are executed, health-checked, and artifacts collected @@ -118,12 +119,12 @@ periodic-ci-redhat-developer-rhdh-{BRANCH}-e2e-{PLATFORM}-{INSTALL_METHOD}[-{VAR Breaking it down: -| Segment | Values | Meaning | -|---------|--------|---------| -| `{BRANCH}` | `main`, `release-1.9`, `release-1.10` | Git branch being tested | -| `{PLATFORM}` | `ocp`, `ocp-v4-{VER}`, `aks`, `eks`, `gke`, `osd-gcp` | Target platform (OCP versions rotate as new releases come out) | -| `{INSTALL_METHOD}` | `helm`, `operator` | Installation method | -| `{VARIANT}` | `auth-providers`, `upgrade` | Optional -- specialized test scenario | +| Segment | Values | Meaning | +| ------------------ | ----------------------------------------------------- | -------------------------------------------------------------- | +| `{BRANCH}` | `main`, `release-1.9`, `release-1.10` | Git branch being tested | +| `{PLATFORM}` | `ocp`, `ocp-v4-{VER}`, `aks`, `eks`, `gke`, `osd-gcp` | Target platform (OCP versions rotate as new releases come out) | +| `{INSTALL_METHOD}` | `helm`, `operator` | Installation method | +| `{VARIANT}` | `auth-providers`, `upgrade` | Optional -- specialized test scenario | Examples: @@ -160,7 +161,7 @@ Prow (scheduler) the test step (2, 3) run inside the [`e2e-runner`](https://quay.io/repository/rhdh-community/rhdh-e2e-runner?tab=tags) image, which is built by a [GitHub Actions workflow](../../.github/workflows/push-e2e-runner.yaml) and mirrored into OpenShift CI. -Each phase can fail independently. Knowing *where* in this pipeline the failure occurred is the first step in triage. +Each phase can fail independently. Knowing _where_ in this pipeline the failure occurred is the first step in triage. --- @@ -200,6 +201,7 @@ While a PR check is running, you can monitor its live progress, logs, and system - **Terminal**: You can open a terminal into a running pod for live debugging This is especially useful when: + - A job is hanging and you want to see what it's doing right now - You need to check pod resource consumption (OOM suspicion) - You want to watch deployment progress in real time rather than waiting for artifacts @@ -263,11 +265,13 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t **OCP cluster pools** (ephemeral, AWS us-east-2): RHDH uses dedicated Hive cluster pools with the `rhdh` prefix. You can find the current list by filtering for `rhdh` in the [existing cluster pools](https://docs.ci.openshift.org/how-tos/cluster-claim/#existing-cluster-pools) page. See also [`.ci/pipelines/README.md`](../../.ci/pipelines/README.md) for which pool is used by which job. **What can go wrong**: + - Cluster pool exhausted (no available clusters) - Cluster claim timeout - Cluster in unhealthy state **How to tell**: + - **OCP**: The job shows status `error` (not `failure`) in Prow. Check `build-log.txt` at the top level for cluster provisioning errors. - **AKS/EKS**: Look for the `create` step in the Prow job artifacts — this is where Mapt provisions the cloud cluster. If it failed, the cluster was never created. @@ -278,6 +282,7 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t **What happens**: ci-operator clones the repo. The test runner image ([`quay.io/rhdh-community/rhdh-e2e-runner`](https://quay.io/repository/rhdh-community/rhdh-e2e-runner?tab=tags)) is mirrored into OpenShift CI and used to run all test steps starting from `openshift-ci-tests.sh`. The image is built by a [GitHub Actions workflow](../../.github/workflows/push-e2e-runner.yaml) from [`.ci/images/Dockerfile`](../../.ci/images/Dockerfile) and pushed to Quay on every push to `main` or `release-*` branches. **What can go wrong**: + - Git clone failures (network/GitHub issues) - Image mirror delay or failure (new image not yet available in CI) @@ -288,15 +293,18 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t ### Phase 3: Cluster Setup (Operators and Prerequisites) **What happens**: The [test script](../../.ci/pipelines/openshift-ci-tests.sh) installs required operators and infrastructure (see [operators.sh](../../.ci/pipelines/lib/operators.sh)): + - OpenShift Pipelines (Tekton) operator - Crunchy PostgreSQL operator **What can go wrong**: + - Operator installation timeout (OperatorHub/Marketplace issues) - CRD not becoming available - Tekton webhook deployment not ready **How to tell**: Search `build-log.txt` for: + - `Failed to install subscription` - Timeout waiting for operator CRDs - `Tekton` or `pipeline` related errors early in the log @@ -308,6 +316,7 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t **What happens**: RHDH is deployed via Helm chart or Operator CR. Health checks poll the Backstage URL. **Helm deployment flow** (see [helm.sh](../../.ci/pipelines/lib/helm.sh)): + 1. Create namespace, RBAC resources, ConfigMaps (see [config.sh](../../.ci/pipelines/lib/config.sh)) 2. Deploy Redis cache 3. Deploy PostgreSQL (for RBAC namespace) @@ -315,6 +324,7 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t 5. Poll health endpoint (up to 30 attempts, 30 seconds apart) via [testing.sh](../../.ci/pipelines/lib/testing.sh) **Operator deployment flow** (see [operator.sh](../../.ci/pipelines/install-methods/operator.sh)): + 1. Install RHDH Operator 2. Wait for `backstages.rhdh.redhat.com` CRD (300s timeout) 3. Create ConfigMaps for dynamic plugins @@ -322,12 +332,14 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t 5. Poll health endpoint **What can go wrong**: + - Helm chart errors (invalid values, missing CRDs) - Pod stuck in `CrashLoopBackOff` (bad config, missing secrets, image pull failure) - Health check timeout (`Failed to reach Backstage after N attempts`) - PostgreSQL operator fails to create user secret (`postgress-external-db-pguser-janus-idp`) **How to tell**: Search `build-log.txt` for: + - `CrashLoopBackOff` -- pod is crash-looping - `Failed to reach Backstage` -- health check timeout - `helm upgrade` failures @@ -341,12 +353,14 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t **What happens**: Playwright tests run inside the test container against the deployed RHDH instance (see [testing.sh](../../.ci/pipelines/lib/testing.sh)). For test configuration details (timeouts, retries, workers), see [`playwright.config.ts`](../../e2e-tests/playwright.config.ts). For project names, see [`projects.json`](../../e2e-tests/playwright/projects.json). **What can go wrong**: + - Individual test failures (assertions, timeouts, element not found) - Authentication/login failures (Keycloak issues) - API timeouts (external service dependencies) - Flaky tests (pass on retry but show up in JUnit XML as failures) **How to tell**: This is the most common scenario. Look at: + - `junit-results-{project}.xml` -- which tests failed - Playwright HTML report -- detailed failure info with screenshots/videos - `test-log.html` -- full Playwright console output @@ -354,6 +368,7 @@ Open `index.html` in a browser from the GCS artifacts. The report contains per-t **Important**: The Playwright exit code is the source of truth. Exit code `0` means all tests ultimately passed (even if some were retried). JUnit XML may still report initial failures for retried tests. **Action**: Review the specific test failures. Check if the failure is: + - **Flaky**: Passed on retry -- file a flaky test ticket - **Consistent**: Fails across retries -- real bug, investigate further - **Broad**: Many tests fail in the same way -- likely a deployment/config issue, not individual test bugs @@ -375,8 +390,9 @@ The most comprehensive nightly job. Runs on OpenShift using ephemeral cluster cl **Namespaces**: `showcase-ci-nightly`, `showcase-rbac-nightly`, `postgress-external-db-nightly`, plus a runtime namespace for `showcase-runtime` tests **Test suites run (in order)**: + 1. **Standard deployment tests** (`showcase`, `showcase-rbac`) -- core functionality with and without RBAC -2. **Runtime config change tests** (`showcase-runtime`) -- tests that modify RHDH configuration at runtime +2. **Runtime config change tests** (`showcase-runtime`) -- tests that modify RHDH configuration at runtime (config-map, schema-mode, and external DB TLS: RDS, Azure, Google Cloud SQL Auth Proxy on Helm). Cloud SQL uses Vault `CLOUDSQL_*` keys, public hosts for `clearDatabase` with `cloudsql-db-certificates.pem` (same CA wipe pattern as RDS/Azure), plus `cloudsql-service-account.json` for the Auth Proxy. Operator Cloud SQL coverage is deferred (RHIDP-9141). 3. **Sanity plugins check** (`showcase-sanity-plugins`) -- validates plugin loading and basic functionality 4. **Localization tests** (`showcase-localization-fr`, `showcase-localization-it`, `showcase-localization-ja`) -- UI translations @@ -391,6 +407,7 @@ Same as OCP nightly but deploys RHDH using the Operator instead of Helm. See [`o **Test suites**: `showcase-operator`, `showcase-operator-rbac` **Key differences**: + - Installs RHDH Operator and waits for `backstages.rhdh.redhat.com` CRD (300s timeout) - Uses Backstage CR (`rhdh-start.yaml`) instead of Helm release - Runtime config tests enabled, including `pluginDivisionMode: schema` tests with external Crunchy PostgreSQL @@ -404,6 +421,7 @@ Runs on every PR that modifies e2e test code. Smaller scope for faster feedback. **Test suites**: `showcase`, `showcase-rbac` only **Key differences**: + - No runtime, sanity plugin, or localization tests - Deploys test Backstage customization provider @@ -416,12 +434,14 @@ Tests authentication provider integrations. Has a completely different deploymen **Release name**: `rhdh-auth-providers` **Providers tested**: + - OIDC via Red Hat Backstage Keycloak (RHBK) - Microsoft OAuth2 - GitHub authentication - LDAP / Active Directory (may be commented out) **Key differences**: + - Uses RHDH **Operator** for deployment (not Helm) - TypeScript-based test configuration (not Bash scripts) -- see [auth-providers test directory](../../e2e-tests/playwright/e2e/auth-providers/) - Dedicated values file: [`values_showcase-auth-providers.yaml`](../../.ci/pipelines/value_files/values_showcase-auth-providers.yaml) @@ -436,6 +456,7 @@ Tests upgrading RHDH from a previous version to the current one. See [`upgrade.s **Namespace**: `showcase-upgrade-nightly` **Flow**: + 1. Dynamically determine the previous release version 2. Deploy RHDH at the previous version 3. Upgrade to the current version @@ -462,6 +483,7 @@ Tests on AWS Elastic Kubernetes Service. See [`eks-helm.sh`](../../.ci/pipelines **Test suites**: `showcase-k8s`, `showcase-rbac-k8s` **Platform specifics** (DNS/cert logic in [`aws.sh`](../../.ci/pipelines/cluster/eks/aws.sh)): + - **Dynamic DNS**: Generates domain names (`eks-ci-{N}.{region}.{parent-domain}`), tries up to 50 numbers - **AWS Certificate Manager**: Requests/retrieves SSL certificates per domain. DNS validation with Route53. - **ALB ingress controller**: AWS Application Load Balancer with SSL redirect -- see [`eks-operator-ingress.yaml`](../../.ci/pipelines/cluster/eks/manifest/eks-operator-ingress.yaml) @@ -478,6 +500,7 @@ Tests on Google Kubernetes Engine. See [`gke-helm.sh`](../../.ci/pipelines/jobs/ **Test suites**: `showcase-k8s`, `showcase-rbac-k8s` **Platform specifics** (cert logic in [`gcloud.sh`](../../.ci/pipelines/cluster/gke/gcloud.sh)): + - Uses a **long-running cluster** (not ephemeral like OCP) - Google-managed SSL certificates via `gcloud` - GCE ingress class with FrontendConfig for SSL policy and HTTPS redirect -- see [`frontend-config.yaml`](../../.ci/pipelines/cluster/gke/manifest/frontend-config.yaml) and [`gke-operator-ingress.yaml`](../../.ci/pipelines/cluster/gke/manifest/gke-operator-ingress.yaml) @@ -493,17 +516,20 @@ Tests on Google Kubernetes Engine. See [`gke-helm.sh`](../../.ci/pipelines/jobs/ The job never got to run tests. Something went wrong with the CI platform itself. **Indicators**: + - Prow shows the job as `error` (red circle) rather than `failure` (red X) - Failure is in `build-log.txt` (top level), not in the test step - `ci-operator.log` shows provisioning or setup errors - No test artifacts for RHDH exist at all **Where to look**: + - Top-level `build-log.txt` - `ci-operator.log` - `ci-operator-step-graph.json` -- shows which step failed **Common causes**: + - Cluster pool exhaustion - Cloud provider API failures (AKS/EKS/GKE auth, quota) - Network/DNS issues at the CI level @@ -516,16 +542,19 @@ The job never got to run tests. Something went wrong with the CI platform itself The cluster was provisioned, but RHDH failed to deploy or start properly. **Indicators**: + - `build-log.txt` (test step) shows deployment errors before any test execution - `pod_logs/` contain application crash logs - No JUnit XML or Playwright report exists for that namespace **Where to look**: + - Test step `build-log.txt` -- search for `CrashLoopBackOff`, `Failed to reach Backstage`, `helm upgrade` errors - `pod_logs/` -- check RHDH container logs for startup errors - Kubernetes events -- look for `ImagePullBackOff`, `FailedScheduling`, etc. **Common causes**: + - Bad configuration in ConfigMaps (see [`resources/config_map/`](../../.ci/pipelines/resources/config_map/)) or values files (see [`value_files/`](../../.ci/pipelines/value_files/)) - Image pull failures (wrong tag, registry auth, rate limiting) - Resource constraints (OOM, CPU limits) @@ -538,21 +567,23 @@ The cluster was provisioned, but RHDH failed to deploy or start properly. RHDH deployed successfully, but one or more Playwright tests failed. **Indicators**: + - JUnit XML and Playwright report exist with specific test failures **Where to look**: + - Playwright HTML report -- screenshots, videos, error messages - `test-log.html` -- full console output of the test run - `pod_logs/` -- if the test failure suggests a backend issue **Subcategories**: -| Pattern | Likely Cause | Action | -|---------|-------------|--------| -| Single test fails, passes on retry | Flaky test | File flaky test ticket | -| Single test fails consistently | Real test bug or app regression | Investigate, file bug | -| Many tests timeout | App slow or partially broken | Check pod logs, resource usage | -| All tests fail uniformly | Deployment issue not caught by health check | Treat as deployment failure | +| Pattern | Likely Cause | Action | +| ---------------------------------- | ------------------------------------------- | ------------------------------ | +| Single test fails, passes on retry | Flaky test | File flaky test ticket | +| Single test fails consistently | Real test bug or app regression | Investigate, file bug | +| Many tests timeout | App slow or partially broken | Check pod logs, resource usage | +| All tests fail uniformly | Deployment issue not caught by health check | Treat as deployment failure | --- @@ -568,7 +599,7 @@ The **AI Test Triager** (`@Nightly Test Alerts` Slack app) is your first stop fo Previously resolved CI failures are tracked in Jira with the **`ci-fail`** label. Search for resolved issues to find patterns, root causes, and fixes for failures you're seeing: -- [Resolved `ci-fail` issues (RHDHBUGS)](https://redhat.atlassian.net/issues/?jql=project%20%3D%20RHDHBUGS%20AND%20labels%20%3D%20ci-fail%20AND%20status%20in%20(Done%2C%20Closed)%20ORDER%20BY%20resolved%20DESC) +- [Resolved `ci-fail` issues (RHDHBUGS)]() When investigating a failure, search these resolved issues for keywords from the error message (e.g., `CrashLoopBackOff`, `Failed to reach Backstage`, `ImagePullBackOff`). The resolution comments often describe exactly what was wrong and how it was fixed. @@ -581,21 +612,23 @@ When investigating a failure, search these resolved issues for keywords from the The **AI Test Triager** is an automated analysis tool integrated into the `@Nightly Test Alerts` Slack app. It significantly speeds up the triage process by doing much of the investigation work for you. **How it works**: + - **Automatically triggered** on every failed nightly job -- the analysis appears alongside the failure alert in Slack. - **Manually invoked** by tagging `@Nightly Test Alerts` in Slack when you want to analyze a specific failure. **What it does**: -| Capability | Description | -|------------|-------------| -| **Artifact inspection** | Reads `build-log.txt`, locates JUnit results, screenshots, and pod logs | -| **JUnit parsing** | Extracts only failed test cases with clean error messages | +| Capability | Description | +| ----------------------- | -------------------------------------------------------------------------------------- | +| **Artifact inspection** | Reads `build-log.txt`, locates JUnit results, screenshots, and pod logs | +| **JUnit parsing** | Extracts only failed test cases with clean error messages | | **Screenshot analysis** | Uses AI vision to interpret failure screenshots and identify what went wrong on screen | -| **Root cause analysis** | Provides a concise 1-2 sentence diagnosis of each failure | -| **Duplicate detection** | Searches Jira for semantically similar existing issues to avoid duplicates | -| **Bug creation** | Can create or update Jira bug tickets with detailed findings | +| **Root cause analysis** | Provides a concise 1-2 sentence diagnosis of each failure | +| **Duplicate detection** | Searches Jira for semantically similar existing issues to avoid duplicates | +| **Bug creation** | Can create or update Jira bug tickets with detailed findings | **Recommended workflow**: + 1. A nightly job fails and the alert appears in Slack with the AI analysis. 2. Review the AI triager's root cause analysis and similar Jira issues. 3. If it's a known issue, confirm and move on. @@ -603,12 +636,12 @@ The **AI Test Triager** is an automated analysis tool integrated into the `@Nigh ### Prow Dashboard -| Link | Description | -|------|-------------| -| [Nightly Jobs (main)](https://prow.ci.openshift.org/?type=periodic&job=periodic-ci-redhat-developer-rhdh-main-e2e-*) | All main branch nightly jobs | -| [Nightly Jobs (all branches)](https://prow.ci.openshift.org/?type=periodic&job=periodic-ci-redhat-developer-rhdh-*-e2e-*) | All nightly jobs across branches | -| [PR Check Jobs](https://prow.ci.openshift.org/?type=presubmit&job=pull-ci-redhat-developer-rhdh-*-e2e-*) | PR presubmit jobs | -| [Configured Jobs](https://prow.ci.openshift.org/configured-jobs/redhat-developer/rhdh) | All configured jobs for the repo | +| Link | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| [Nightly Jobs (main)](https://prow.ci.openshift.org/?type=periodic&job=periodic-ci-redhat-developer-rhdh-main-e2e-*) | All main branch nightly jobs | +| [Nightly Jobs (all branches)](https://prow.ci.openshift.org/?type=periodic&job=periodic-ci-redhat-developer-rhdh-*-e2e-*) | All nightly jobs across branches | +| [PR Check Jobs](https://prow.ci.openshift.org/?type=presubmit&job=pull-ci-redhat-developer-rhdh-*-e2e-*) | PR presubmit jobs | +| [Configured Jobs](https://prow.ci.openshift.org/configured-jobs/redhat-developer/rhdh) | All configured jobs for the repo | | [Job History (example)](https://prow.ci.openshift.org/job-history/gs/test-platform-results/logs/periodic-ci-redhat-developer-rhdh-main-e2e-ocp-helm-nightly) | Historical runs for a specific job | ### Accessing Artifacts Directly diff --git a/docs/e2e-tests/CI.md b/docs/e2e-tests/CI.md index 5b3ed60201..76db72aa77 100644 --- a/docs/e2e-tests/CI.md +++ b/docs/e2e-tests/CI.md @@ -27,6 +27,7 @@ For scenarios where tests are not automatically triggered, or when you need to m - `/test ?` to get a list of all available jobs - `/test e2e-ocp-helm` for mandatory PR checks - **Note:** Avoid using `/test all` as it may trigger unnecessary jobs and consume CI resources. Instead, use `/test ?` to see available options and trigger only the specific tests you need. + - **When changing e2e tests or shared e2e infrastructure:** also trigger the optional/nightly jobs that exercise the paths you touched (for example localization, runtime, auth-providers, operator). The mandatory PR check alone does not cover those suites. Comment `/test ?`, pick the matching short job names from the bot reply, run `/test `, and wait for results before merging. See [CONTRIBUTING.MD](CONTRIBUTING.MD#verify-affected-ci-jobs-before-merging). 3. **Triggering Optional Nightly Job Execution on Pull Requests:** The following optional nightly jobs can be manually triggered on PRs targeting the `main` branch and `release-*` branches. These jobs help validate changes across various deployment environments by commenting the trigger command on PR. @@ -55,7 +56,7 @@ If the initial automatically triggered tests fail, OpenShift-CI will add a comme - **Environment:** Runs on ephemeral OpenShift clusters managed by Hive. Kubernetes jobs use ephemeral EKS and AKS clusters on spot instances managed by [Mapt](https://github.com/redhat-developer/mapt). GKE uses a long-running cluster. - **Configurations:** - Tests are executed on both **RBAC** (Role-Based Access Control) and **non-RBAC** namespaces. Different sets of tests are executed for both the **non-RBAC RHDH instance** and the **RBAC RHDH instance**, each deployed in separate namespaces. -- **Access:** In order to access the environment, you can run the bash at `.ci/pipelines/ocp-cluster-claim-login.sh`. You will be prompted the prow url (the url from the openshift agent, which looks like https://prow.ci.openshift.org/...). Once you have claimed a cluster, this script will forward the cluster web console url along with the credentials. +- **Access:** In order to access the environment, you can run the bash at `.ci/pipelines/ocp-cluster-claim-login.sh`. You will be prompted the prow url (the url from the openshift agent, which looks like ...). Once you have claimed a cluster, this script will forward the cluster web console url along with the credentials. - **Steps:** 1. **Detection:** OpenShift-CI detects the PR event. 2. **Environment Setup:** The test environment is set up using the `openshift-ci-tests.sh` script (see the [High-Level Overview](#high-level-overview-of-openshift-ci-testssh)). diff --git a/docs/e2e-tests/CONTRIBUTING.MD b/docs/e2e-tests/CONTRIBUTING.MD index 8a61bcb766..7f21c329a1 100644 --- a/docs/e2e-tests/CONTRIBUTING.MD +++ b/docs/e2e-tests/CONTRIBUTING.MD @@ -58,6 +58,7 @@ These principles are valid for new contributions. Some parts of the codebase may - Use `guestPage` for ordinary specs that need a fresh guest login per test (best isolation; default choice). - Use `rhdhGuestPage` or `rhdhPage` only when a describe block intentionally shares one browser context/page across tests (for example to avoid repeated guest login or amortize expensive setup). - Worker-scoped fixtures (`rhdhPage`, `rhdhGuestPage`, `rhdhContext`, `rhdhAuthSession`) do **not** require `test.describe.configure({ mode: "serial" })` by themselves. With the default Playwright config, tests in a file already run sequentially in one worker. + - `test.use({ baseURL })` only affects test-scoped fixtures (built-in `page`). Worker-scoped `rhdhContext` / `rhdhPage` read project `use.baseURL` (`BASE_URL`) unless you also set the worker option. Auth-provider specs should call `createAuthProviderHarness(...)` from `auth-provider-playwright.ts` at **file top level** (it binds `baseURL` + `workerBaseURL`). Do not call it inside `describe` / hooks — Playwright rejects worker-scoped `test.use` there. Prefer that over bare `AuthProviderHarness.create` (no Playwright binding). For other shared-context specs with an empty project `BASE_URL`, call `useRhdhBaseURL(instanceUrl)` at file top level. - When sharing a worker-scoped page/context across multiple tests, reset browser state between tests (sign out, `clearCookies`, or a harness `cleanup` callback) so each test stays independent. - Use `test.describe.configure({ mode: "serial" })` (or `test.describe.serial`) **only** when a later test depends on side effects from an earlier test in the same file—for example, import an entity in test 1 and verify it in test 2. Do not add serial merely because worker fixtures are present. @@ -85,6 +86,18 @@ These principles are valid for new contributions. Some parts of the codebase may When contributing new tests or modifying existing ones, please adhere to the guidelines. To open a PR, follow the steps described in the [general contribution guide](../../CONTRIBUTING.md) +### Verify Affected CI Jobs Before Merging + +Changing e2e specs or shared e2e infrastructure (fixtures, `global-setup.ts`, `playwright.config.ts`, deploy helpers, auth harness, localization) can break nightly or optional jobs that do **not** run automatically on every PR. + +On your PR: + +1. Comment `/test ?` to list available Prow jobs for that branch. +2. Comment `/test ` for each job your change affects (for example `e2e-ocp-helm-nightly`, `e2e-ocp-operator-nightly`, `e2e-ocp-helm-localization-nightly`, `e2e-ocp-operator-auth-providers-nightly`). +3. Wait for those jobs to finish and confirm they still pass, or are not worse than before. + +Do not use `/test all` unless you intentionally need every optional job. Details and job naming are in [CI.md](CI.md). + ### Collaboration and Communication - **Team Consensus:** diff --git a/e2e-tests/.lintstagedrc.js b/e2e-tests/.lintstagedrc.js new file mode 100644 index 0000000000..2bcef6640d --- /dev/null +++ b/e2e-tests/.lintstagedrc.js @@ -0,0 +1,14 @@ +/** + * @type {import('lint-staged').Configuration} + */ +export default { + "*.sh": [ + "shellcheck --severity=warning --color=always", + "prettier --write --plugin=prettier-plugin-sh", + ], + "*": (filenames) => { + const nonShell = filenames.filter((file) => !file.endsWith(".sh")); + return nonShell.length > 0 ? [`oxfmt --write ${nonShell.join(" ")}`] : []; + }, + "*.{js,jsx,ts,tsx,mjs,cjs}": "yarn lint:fix", +}; diff --git a/e2e-tests/.prettierrc.cjs b/e2e-tests/.prettierrc.cjs new file mode 100644 index 0000000000..065695b5b4 --- /dev/null +++ b/e2e-tests/.prettierrc.cjs @@ -0,0 +1,27 @@ +// @ts-check + +/** @type {import("prettier").Config} */ +module.exports = { + plugins: ["prettier-plugin-sh"], + overrides: [ + { + files: "*.sh", + options: { + parser: "sh", + keepComments: true, + indent: 2, + endOfLine: "lf", + }, + }, + ], + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: "all", + bracketSpacing: true, + bracketSameLine: false, + arrowParens: "always", + endOfLine: "lf", +}; diff --git a/e2e-tests/package.json b/e2e-tests/package.json index b8bd0dc231..9ded4a4411 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -57,6 +57,8 @@ "oxfmt": "0.56.0", "oxlint": "1.71.0", "oxlint-tsgolint": "0.23.0", + "prettier": "3.8.4", + "prettier-plugin-sh": "0.18.1", "shellcheck": "4.1.0", "typescript": "6.0.3", "vitest": "4.1.10" diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 9f055740b8..5d1d2fdc53 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ "**/playwright/e2e/auth-providers/**/*.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", "**/playwright/e2e/configuration-test/config-map.spec.ts", ], @@ -112,6 +113,9 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_AUTH_PROVIDERS, + // One worker avoids cross-file races on shared IdP app registrations + // (Azure redirect URIs) while each describe still owns its own namespace. + workers: 1, timeout: 600 * 1000, testMatch: ["**/playwright/e2e/auth-providers/*.spec.ts"], testIgnore: [ @@ -119,6 +123,7 @@ export default defineConfig({ "**/playwright/e2e/auth-providers/github-happy-path.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts", ], retries: 1, }, @@ -139,6 +144,7 @@ export default defineConfig({ "**/playwright/e2e/audit-log/**/*.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts", "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", @@ -164,6 +170,7 @@ export default defineConfig({ "**/playwright/e2e/audit-log/**/*.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts", "**/playwright/e2e/configuration-test/config-map.spec.ts", "**/playwright/e2e/github-happy-path.spec.ts", "**/playwright/e2e/plugin-division-mode-schema/*.spec.ts", @@ -183,6 +190,7 @@ export default defineConfig({ "**/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", + "**/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts", ], }, @@ -207,7 +215,6 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_DE, - dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "de", }, @@ -219,7 +226,6 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_ES, - dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "es", }, @@ -231,7 +237,6 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_FR, - dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "fr", }, @@ -243,7 +248,6 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_IT, - dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "it", }, @@ -255,7 +259,6 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_JA, - dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "ja", }, diff --git a/e2e-tests/playwright/e2e/auth-providers/README.md b/e2e-tests/playwright/e2e/auth-providers/README.md index 973a256520..9fe654e8d5 100644 --- a/e2e-tests/playwright/e2e/auth-providers/README.md +++ b/e2e-tests/playwright/e2e/auth-providers/README.md @@ -13,9 +13,9 @@ For each providers the tests verify: - the supported resolvers - the users and group ingestion - that nested groups are ingested correctly -- the session token duration can be configured +- the session token duration can be configured (`auth.providers...sessionDuration`) -Since changing any setting in the authentication providers configuration require to restart RHDH, the tests are dynamically configuring, creating, updating, restarting and deleting the RHDH instances. +Since changing any setting in the authentication providers configuration require to restart RHDH, the tests are dynamically configuring, creating, updating, restarting and deleting the RHDH instances. Reconcile after config change proves the new app-config is mounted and HTTP-ready; catalog sync is only waited when a test opts in. The main target for these tests suites is Openshift and our CI environment. If you want to use your cluster as target, make sure you are currently logged in to an Openshift cluster as admin and your kubeconfig is currently exported. The tests will create a client from it and start creating the necessary resources. These tests rely on the RHDH Operator, make sure you have it installed in your cluster before running the tests. diff --git a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts index 6e87d44e01..f26fad47ba 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -1,8 +1,13 @@ import { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; +import type { LoginOutcome } from "../../support/auth/app-shell"; import { AuthProviderSession } from "../../support/auth/provider-auth"; -import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { createAuthProviderHarness } from "../../support/fixtures/auth-provider-playwright"; import { SettingsPage } from "../../support/pages/settings-page"; +import { + THREE_DAYS_MS, + isRefreshTokenDurationNear, +} from "../../utils/authentication-providers/auth-cookie-duration"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; /* SUPORTED RESOLVERS @@ -13,11 +18,9 @@ GITHUB: [x] emailLocalPartMatchingUserEntityName */ -const harness = AuthProviderHarness.create("albarbaro-test-namespace-github"); +const harness = createAuthProviderHarness("albarbaro-test-namespace-github"); test.describe("Configure Github Provider", () => { - test.use({ baseURL: harness.backstageUrl }); - let authSession: AuthProviderSession; let settingsPage: SettingsPage; let page: Page; @@ -27,7 +30,7 @@ test.describe("Configure Github Provider", () => { await authSession.clearAuthState(context); } - function loginAsGithubAdmin(): Promise { + function loginAsGithubAdmin(): Promise { return authSession.loginWithGitHub( "rhdhqeauthadmin", process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, @@ -35,7 +38,7 @@ test.describe("Configure Github Provider", () => { ); } - function loginAsGithubUser(): Promise { + function loginAsGithubUser(): Promise { return authSession.loginWithGitHub( "rhdhqeauth1", process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, @@ -121,6 +124,7 @@ test.describe("Configure Github Provider", () => { await harness.reconcileAfterConfigChange(); }, login: loginAsGithubUser, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }, @@ -135,6 +139,7 @@ test.describe("Configure Github Provider", () => { await harness.reconcileAfterConfigChange(); }, login: loginAsGithubUser, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }, @@ -142,13 +147,11 @@ test.describe("Configure Github Provider", () => { }); }); - test(`Set Github sessionDuration and confirm in auth cookie duration has been set`, async () => { + test(`Set Github sessionDuration and confirm auth cookie duration has been set`, async () => { await harness.runLoginCase({ configure: async () => { - harness.deployment.setAppConfigProperty( - "auth.providers.github.production.sessionDuration", - "3days", - ); + // Pin resolver + duration together — earlier resolver tests share this namespace. + harness.deployment.configureGithubSessionDuration("3days"); await harness.reconcileAfterConfigChange(); }, login: loginAsGithubAdmin, @@ -157,14 +160,7 @@ test.describe("Configure Github Provider", () => { const cookies = await context.cookies(); const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); - expect(authCookie).toBeDefined(); - - const threeDays = 3 * 24 * 60 * 60 * 1000; - const tolerance = 3 * 60 * 1000; - const actualDuration = authCookie!.expires * 1000 - Date.now(); - - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); + expect(isRefreshTokenDurationNear(authCookie, THREE_DAYS_MS)).toBe(true); await settingsPage.open(); await settingsPage.verifyProfileHeading("RHDH QE Admin"); @@ -175,41 +171,40 @@ test.describe("Configure Github Provider", () => { }); test(`Ingestion of Github users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - await expect - .poll( - () => harness.deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), - { timeout: 120_000 }, - ) - .toBe(true); - expect( - await harness.deployment.checkGroupIsIngestedInCatalog([ - "test_admins", - "test_all", - "test_users", - ]), - ).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins")).toBe( - true, - ); - expect(await harness.deployment.checkUserIsInGroup("rhdhqeauth1", "test_users")).toBe(true); - - expect(await harness.deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); - expect(await harness.deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); - - expect( - await harness.deployment.checkUserHasAnnotation( + await expect( + harness.deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsIngestedInCatalog(["test_admins", "test_all", "test_users"]), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("rhdhqeauth1", "test_users"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("test_users", "test_all"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsChildOfGroup("test_admins", "test_all"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkUserHasAnnotation( "rhdhqeauthadmin", "MY_CUSTOM_ANNOTATION", "rhdhqeauthadmin", ), - ).toBe(true); - expect( - await harness.deployment.checkUserHasAnnotation( + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserHasAnnotation( "rhdhqeauth1", "MY_CUSTOM_ANNOTATION", "rhdhqeauth1", ), - ).toBe(true); + ).resolves.toBeUndefined(); }); test("Login with Github as only auth provider with disableIdentityResolution should fail", async () => { @@ -217,11 +212,12 @@ test.describe("Configure Github Provider", () => { configure: async () => { harness.deployment.setAppConfigProperty( "auth.providers.github.production.disableIdentityResolution", - "true", + true, ); await harness.reconcileAfterConfigChange(); }, login: loginAsGithubUser, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError( /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/u, diff --git a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts index f61c057449..9f321beb4e 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type BrowserContext } from "@support/coverage/test"; import { AuthProviderSession } from "../../support/auth/provider-auth"; -import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { createAuthProviderHarness } from "../../support/fixtures/auth-provider-playwright"; import { SettingsPage } from "../../support/pages/settings-page"; import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; @@ -13,11 +13,9 @@ GITLAB: [x] emailLocalPartMatchingUserEntityName */ -const harness = AuthProviderHarness.create("albarbaro-test-namespace-gitlab"); +const harness = createAuthProviderHarness("albarbaro-test-namespace-gitlab"); test.describe("Configure GitLab Provider", () => { - test.use({ baseURL: harness.backstageUrl }); - let authSession: AuthProviderSession; let settingsPage: SettingsPage; let context: BrowserContext; @@ -101,63 +99,70 @@ test.describe("Configure GitLab Provider", () => { }); test(`Ingestion of GitLab users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - await expect - .poll( - () => - harness.deployment.checkUserIsIngestedInCatalog([ - "user1", - "user2", - "user3", - "Administrator", - ]), - { timeout: 120_000 }, - ) - .toBe(true); - expect( - await harness.deployment.checkGroupIsIngestedInCatalog([ + await expect( + harness.deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsIngestedInCatalog([ "my-org", "group1", "all", "nested", "nested_2", ]), - ).toBe(true); - - expect(await harness.deployment.checkUserIsInGroup("user1", "all")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("user2", "all")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("user3", "all")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("root", "all")).toBe(true); - - expect(await harness.deployment.checkUserIsInGroup("root", "group1")).toBe(true); - - expect(await harness.deployment.checkUserIsInGroup("user1", "group1-nested")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("user2", "group1-nested")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("root", "group1-nested")).toBe(true); - - expect(await harness.deployment.checkUserIsInGroup("user3", "group1-nested-nested_2")).toBe( - true, - ); - expect(await harness.deployment.checkUserIsInGroup("root", "group1-nested-nested_2")).toBe( - true, - ); - - expect(await harness.deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe(true); - - expect(await harness.deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe(true); - - expect(await harness.deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe( - true, - ); - - expect( - await harness.deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), - ).toBe(true); - expect( - await harness.deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), - ).toBe(true); + ).resolves.toBeUndefined(); + + await expect(harness.deployment.checkUserIsInGroup("user1", "all")).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("user2", "all")).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("user3", "all")).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("root", "all")).resolves.toBeUndefined(); + + await expect(harness.deployment.checkUserIsInGroup("root", "group1")).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkUserIsInGroup("user1", "group1-nested"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("user2", "group1-nested"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("root", "group1-nested"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkUserIsInGroup("user3", "group1-nested-nested_2"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("root", "group1-nested-nested_2"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("group1", "my-org"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("my-org", "group1"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("all", "my-org"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("my-org", "all"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("group1-nested", "group1"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("group1", "group1-nested"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), + ).resolves.toBeUndefined(); }); test.afterAll(async () => { diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index d5649f57df..a7849d0d4d 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@support/coverage/test"; import { AuthProviderSession } from "../../support/auth/provider-auth"; -import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { createAuthProviderHarness } from "../../support/fixtures/auth-provider-playwright"; import { SettingsPage } from "../../support/pages/settings-page"; import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; @@ -10,13 +10,11 @@ LDAP: [x] oidcLdapUuidMatchingAnnotation -> (Default) */ -const harness = AuthProviderHarness.create("albarbaro-test-namespace-ldap"); +const harness = createAuthProviderHarness("albarbaro-test-namespace-ldap"); let nsgCleanup: (() => Promise) | undefined; test.describe("Configure LDAP Provider", () => { - test.use({ baseURL: harness.backstageUrl }); - let authSession: AuthProviderSession; let settingsPage: SettingsPage; let clearSession: (() => Promise) | undefined; @@ -122,17 +120,12 @@ test.describe("Configure LDAP Provider", () => { }); test(`Ingestion of LDAP users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - expect( - await harness.deployment.checkUserIsIngestedInCatalog([ - "User 1", - "User 2", - "User 3", - "RHDH Admin", - ]), - ).toBe(true); + await expect( + harness.deployment.checkUserIsIngestedInCatalog(["User 1", "User 2", "User 3", "RHDH Admin"]), + ).resolves.toBeUndefined(); - expect( - await harness.deployment.checkGroupIsIngestedInCatalog([ + await expect( + harness.deployment.checkGroupIsIngestedInCatalog([ "Admins", "All_Users", "testGroup", @@ -140,23 +133,29 @@ test.describe("Configure LDAP Provider", () => { "testSubSubGroup", "SubAdmins", ]), - ).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("user1", "All_Users")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("user2", "All_Users")).toBe(true); - - expect(await harness.deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup")).toBe( - true, - ); - expect( - await harness.deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup"), - ).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup")).toBe( - true, - ); - expect( - await harness.deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup"), - ).toBe(true); + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("rhdh-admin", "Admins"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("user1", "All_Users"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("user2", "All_Users"), + ).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup"), + ).resolves.toBeUndefined(); }); test("Login with PingFederate OIDC (with LDAP catalog)", async () => { diff --git a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts index 77e4d6e478..450c517e73 100644 --- a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts @@ -1,8 +1,13 @@ import { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; +import type { LoginOutcome } from "../../support/auth/app-shell"; import { AuthProviderSession } from "../../support/auth/provider-auth"; -import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { createAuthProviderHarness } from "../../support/fixtures/auth-provider-playwright"; import { SettingsPage } from "../../support/pages/settings-page"; +import { + THREE_DAYS_MS, + isRefreshTokenDurationNear, +} from "../../utils/authentication-providers/auth-cookie-duration"; import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; @@ -14,11 +19,9 @@ MICOROSFT: [-] emailLocalPartMatchingUserEntityName */ -const harness = AuthProviderHarness.create("albarbaro-test-namespace-msgraph"); +const harness = createAuthProviderHarness("albarbaro-test-namespace-msgraph"); test.describe("Configure Microsoft Provider", () => { - test.use({ baseURL: harness.backstageUrl }); - let authSession: AuthProviderSession; let settingsPage: SettingsPage; let page: Page; @@ -28,21 +31,21 @@ test.describe("Configure Microsoft Provider", () => { await authSession.clearAuthState(context); } - function loginAsZeus(): Promise { + function loginAsZeus(): Promise { return authSession.loginWithMicrosoftAzure( "zeus@rhdhtesting.onmicrosoft.com", process.env.DEFAULT_USER_PASSWORD_2!, ); } - function loginAsAtena(): Promise { + function loginAsAtena(): Promise { return authSession.loginWithMicrosoftAzure( "atena@rhdhtesting.onmicrosoft.com", process.env.DEFAULT_USER_PASSWORD_2!, ); } - function loginAsTyke(): Promise { + function loginAsTyke(): Promise { return authSession.loginWithMicrosoftAzure( "tyke@rhdhtesting.onmicrosoft.com", process.env.DEFAULT_USER_PASSWORD_2!, @@ -128,6 +131,7 @@ test.describe("Configure Microsoft Provider", () => { await harness.runLoginCase({ login: loginAsAtena, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }, @@ -172,6 +176,7 @@ test.describe("Configure Microsoft Provider", () => { await harness.runLoginCase({ login: loginAsTyke, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }, @@ -179,13 +184,10 @@ test.describe("Configure Microsoft Provider", () => { }); }); - test(`Set Micrisoft sessionDuration and confirm in auth cookie duration has been set`, async () => { + test(`Set Microsoft sessionDuration and confirm auth cookie duration has been set`, async () => { await harness.runLoginCase({ configure: async () => { - harness.deployment.setAppConfigProperty( - "auth.providers.microsoft.production.sessionDuration", - "3days", - ); + harness.deployment.configureMicrosoftSessionDuration("3days"); await harness.reconcileAfterConfigChange(); }, login: loginAsZeus, @@ -194,14 +196,7 @@ test.describe("Configure Microsoft Provider", () => { const cookies = await context.cookies(); const authCookie = cookies.find((cookie) => cookie.name === "microsoft-refresh-token"); - expect(authCookie).toBeDefined(); - - const threeDays = 3 * 24 * 60 * 60 * 1000; - const tolerance = 3 * 60 * 1000; - const actualDuration = authCookie!.expires * 1000 - Date.now(); - - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); + expect(isRefreshTokenDurationNear(authCookie, THREE_DAYS_MS)).toBe(true); await settingsPage.open(); await settingsPage.verifyProfileHeading("TEST Zeus"); @@ -212,88 +207,60 @@ test.describe("Configure Microsoft Provider", () => { }); test(`Ingestion of Microsoft users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - await expect - .poll( - () => - harness.deployment.checkUserIsIngestedInCatalog([ - "TEST Admin", - "TEST Atena", - "TEST Elio", - "TEST Tyke", - "TEST Zeus", - ]), - { timeout: 120_000 }, - ) - .toBe(true); - expect( - await harness.deployment.checkGroupIsIngestedInCatalog([ + await expect( + harness.deployment.checkUserIsIngestedInCatalog([ + "TEST Admin", + "TEST Atena", + "TEST Elio", + "TEST Tyke", + "TEST Zeus", + ]), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsIngestedInCatalog([ "TEST_admins", "TEST_goddesses", "TEST_gods", "TEST_all", ]), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup( - "admin_rhdhtesting.onmicrosoft.com", - "TEST_admins", - ), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup( - "zeus_rhdhtesting.onmicrosoft.com", - "TEST_admins", - ), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup( - "atena_rhdhtesting.onmicrosoft.com", - "TEST_goddesses", - ), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup( - "tiche_rhdhtesting.onmicrosoft.com", - "TEST_goddesses", - ), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup("elio_rhdhtesting.onmicrosoft.com", "TEST_gods"), - ).toBe(true); - expect( - await harness.deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_gods"), - ).toBe(true); + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("admin_rhdhtesting.onmicrosoft.com", "TEST_admins"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_admins"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("atena_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("tiche_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("elio_rhdhtesting.onmicrosoft.com", "TEST_gods"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_gods"), + ).resolves.toBeUndefined(); - //expect(await harness.deployment.checkUserIsInGroup('zeus', 'all')).toBe(true); - //expect(await harness.deployment.checkUserIsInGroup('tyke', 'all')).toBe(true); - expect(await harness.deployment.checkGroupIsChildOfGroup("test_gods", "test_all")).toBe(true); - expect(await harness.deployment.checkGroupIsChildOfGroup("test_goddesses", "test_all")).toBe( - true, - ); - expect(await harness.deployment.checkGroupIsParentOfGroup("test_all", "test_gods")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("test_all", "test_goddesses")).toBe( - true, - ); + await expect( + harness.deployment.checkGroupIsChildOfGroup("test_gods", "test_all"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsChildOfGroup("test_goddesses", "test_all"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("test_all", "test_gods"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("test_all", "test_goddesses"), + ).resolves.toBeUndefined(); }); test.afterAll(async () => { - try { - console.log("[TEST] Cleaning up Microsoft Azure App Registration..."); - const graphClient = new MSClient( - process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, - process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, - process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, - ); - - const redirectUrl = `${harness.backstageUrl}/api/auth/microsoft/handler/frame`; - console.log(`[TEST] Removing redirect URL: ${redirectUrl}`); - await graphClient.removeAppRedirectUrlsAsync([redirectUrl]); - console.log("[TEST] Microsoft Azure App Registration cleanup completed"); - } catch (error) { - console.error("[TEST] Failed to cleanup Microsoft Azure App Registration:", error); - // Don't fail the test cleanup if Azure cleanup fails - } - + // Keep the Azure redirect URI registered. The albarbaro-* hostnames are + // intentionally stable for this suite; removing mid-job races with retries + // and Graph API eventual consistency (AADSTS50011). await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts index 95fb8d376b..1b4102a719 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -1,8 +1,14 @@ import { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; +import type { LoginOutcome } from "../../support/auth/app-shell"; import { AuthProviderSession } from "../../support/auth/provider-auth"; -import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { createAuthProviderHarness } from "../../support/fixtures/auth-provider-playwright"; import { SettingsPage } from "../../support/pages/settings-page"; +import { + THREE_DAYS_MS, + isRefreshTokenDurationNear, + waitForNamedCookieAbsent, +} from "../../utils/authentication-providers/auth-cookie-duration"; import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; @@ -17,7 +23,7 @@ OIDC: [-] oidcSubClaimMatchingPingIdentityUserId -> Ping Identity not supported */ -const harness = AuthProviderHarness.create("albarbaro-test-namespace-oidc"); +const harness = createAuthProviderHarness("albarbaro-test-namespace-oidc"); const keycloakHelper = new KeycloakHelper({ baseUrl: process.env.RHBK_BASE_URL!, @@ -27,8 +33,6 @@ const keycloakHelper = new KeycloakHelper({ }); test.describe("Configure OIDC provider (using RHBK)", () => { - test.use({ baseURL: harness.backstageUrl }); - let authSession: AuthProviderSession; let settingsPage: SettingsPage; let page: Page; @@ -38,11 +42,11 @@ test.describe("Configure OIDC provider (using RHBK)", () => { await authSession.clearAuthState(context); } - function loginAsZeus(): Promise { + function loginAsZeus(): Promise { return authSession.loginWithKeycloak("zeus", process.env.DEFAULT_USER_PASSWORD!); } - function loginAsAtena(): Promise { + function loginAsAtena(): Promise { return authSession.loginWithKeycloak("atena", process.env.DEFAULT_USER_PASSWORD!); } @@ -155,6 +159,7 @@ test.describe("Configure OIDC provider (using RHBK)", () => { await harness.runLoginCase({ login: loginAsAtena, + expectedResult: "error", assert: async () => { await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await keycloakHelper.initialize(); @@ -206,13 +211,10 @@ test.describe("Configure OIDC provider (using RHBK)", () => { }); }); - test(`Set sessionDuration and confirm in auth cookie duration has been set`, async () => { + test(`Set OIDC sessionDuration and confirm auth cookie duration has been set`, async () => { await harness.runLoginCase({ configure: async () => { - harness.deployment.setAppConfigProperty( - "auth.providers.oidc.production.sessionDuration", - "3days", - ); + harness.deployment.configureOidcSessionDuration("3days"); await harness.reconcileAfterConfigChange(); }, login: loginAsZeus, @@ -221,14 +223,7 @@ test.describe("Configure OIDC provider (using RHBK)", () => { const cookies = await context.cookies(); const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); - expect(authCookie).toBeDefined(); - - const threeDays = 3 * 24 * 60 * 60 * 1000; - const tolerance = 3 * 60 * 1000; - const actualDuration = authCookie!.expires * 1000 - Date.now(); - - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); + expect(isRefreshTokenDurationNear(authCookie, THREE_DAYS_MS)).toBe(true); await settingsPage.open(); await settingsPage.verifyProfileHeading("Zeus Giove"); @@ -239,36 +234,50 @@ test.describe("Configure OIDC provider (using RHBK)", () => { }); test(`Ingestion of users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - expect( - await harness.deployment.checkUserIsIngestedInCatalog([ + await expect( + harness.deployment.checkUserIsIngestedInCatalog([ "Admin E2e", "Atena Minerva", "Elio Sole", "Tyke Fortuna", "Zeus Giove", ]), - ).toBe(true); - expect( - await harness.deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"]), - ).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("admin", "admins")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("atena", "goddesses")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("tyke", "goddesses")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("elio", "gods")).toBe(true); - expect(await harness.deployment.checkUserIsInGroup("zeus", "gods")).toBe(true); - - expect(await harness.deployment.checkGroupIsChildOfGroup("gods", "all")).toBe(true); - expect(await harness.deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); - expect(await harness.deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe(true); + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"]), + ).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("admin", "admins")).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("zeus", "admins")).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("atena", "goddesses"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkUserIsInGroup("tyke", "goddesses"), + ).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("elio", "gods")).resolves.toBeUndefined(); + await expect(harness.deployment.checkUserIsInGroup("zeus", "gods")).resolves.toBeUndefined(); + + await expect( + harness.deployment.checkGroupIsChildOfGroup("gods", "all"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsChildOfGroup("goddesses", "all"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("all", "gods"), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsParentOfGroup("all", "goddesses"), + ).resolves.toBeUndefined(); }); test(`Ingestion of users and groups with invalid characters: check sanitize[User/Group]NameTransformer`, async () => { - expect(await harness.deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); - expect(await harness.deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe( - true, - ); + await expect( + harness.deployment.checkUserIsIngestedInCatalog(["Invalid Username"]), + ).resolves.toBeUndefined(); + await expect( + harness.deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"]), + ).resolves.toBeUndefined(); }); test("Ensure Guest login is disabled when setting environment to production", async () => { @@ -281,25 +290,23 @@ test.describe("Configure OIDC provider (using RHBK)", () => { expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!).toBeDefined(); expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!).toBeDefined(); - const result = await loginAsZeus(); - expect(result).toBe("Login successful"); - - await settingsPage.open(); - await settingsPage.verifyProfileHeading("Zeus Giove"); - harness.deployment.setAppConfigProperty("auth.providers.github", { production: { clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + disableIdentityResolution: true, }, }); - harness.deployment.setAppConfigProperty( - "auth.providers.github.production.disableIdentityResolution", - "true", - ); + // Reconcile before primary login — restart drops the session, so OIDC + // must happen after the GitHub secondary provider is already configured. await harness.reconcileAfterConfigChange(); + const result = await loginAsZeus(); + expect(result).toBe("authenticated"); + + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await settingsPage.hideQuickstartIfVisible(); const ghLogin = await authSession.loginWithGitHubFromSettingsPage( @@ -307,9 +314,8 @@ test.describe("Configure OIDC provider (using RHBK)", () => { process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, process.env.AUTH_PROVIDERS_GH_USER_2FA!, ); - expect(ghLogin).toBe("Login successful"); - // Intentional divergence: GitHub provider settings expose sign-out via title tooltip. - await page.getByTitle("Sign out from GitHub").click(); + expect(ghLogin).toBe("authenticated"); + await settingsPage.signOutFromAuthProvider("GitHub"); await settingsPage.open(); await settingsPage.verifyProfileHeading("Zeus Giove"); @@ -322,9 +328,10 @@ test.describe("Configure OIDC provider (using RHBK)", () => { test(`Enable autologout and user is logged out after inactivity`, async () => { await harness.runLoginCase({ configure: async () => { - harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); - harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + harness.deployment.configureOidcAutologout({ + idleTimeoutMinutes: 0.5, + promptBeforeIdleSeconds: 5, + }); await harness.reconcileAfterConfigChange(); }, login: loginAsZeus, @@ -333,10 +340,8 @@ test.describe("Configure OIDC provider (using RHBK)", () => { await settingsPage.verifyInactivityLogoutMessageHidden(); await page.reload(); - - const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); - expect(authCookie).toBeUndefined(); + // Idle logout can clear the shell before the httpOnly refresh cookie drops. + await waitForNamedCookieAbsent(context, "oidc-refresh-token"); }, cleanup: clearSession, }); @@ -345,9 +350,10 @@ test.describe("Configure OIDC provider (using RHBK)", () => { test(`Enable autologout and user stays logged in after clicking "Don't log me out"`, async () => { await harness.runLoginCase({ configure: async () => { - harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); - harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + harness.deployment.configureOidcAutologout({ + idleTimeoutMinutes: 0.5, + promptBeforeIdleSeconds: 5, + }); await harness.reconcileAfterConfigChange(); }, login: loginAsZeus, diff --git a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts index a940f1ca0a..1649bb245c 100644 --- a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts +++ b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts @@ -32,7 +32,9 @@ test.describe("Change app-config at e2e test runtime", () => { await runtimeHarness.restartDeploymentWithRetry(); await runtimeHarness.verifyGuestSession(page); - await new HomePage(page).openHomeSidebar(); + const homePage = new HomePage(page); + await homePage.verifyWelcomeHeading(); + await homePage.openHomeSidebar(); console.log("Verifying new title in the UI... "); expect(await page.title()).toContain(dynamicTitle); console.log("Title successfully verified in the UI."); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts index 9f2b62861c..6c45a8f286 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; import { HomePage } from "../../support/pages/home-page"; @@ -59,9 +59,10 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt }); for (const config of azureConfigurations) { - test.describe.serial(`Azure DB ${config.name} PostgreSQL version`, () => { + // Configure lives in beforeAll so CI retries of Verify do not redeploy. + test.describe(`Azure DB ${config.name} PostgreSQL version`, () => { test.beforeAll(async ({}, testInfo) => { - test.setTimeout(180_000); + test.setTimeout(600_000); if (config.host === undefined || config.host === "") { testInfo.skip(true, `AZURE_DB_*_HOST not set for ${config.name} — skipping`); return; @@ -76,14 +77,6 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt password: azurePassword!, certificatePath: process.env.AZURE_DB_CERTIFICATES_PATH, }); - }); - - test("Configure and restart deployment", async ({}, testInfo) => { - if (config.host === undefined || config.host === "") { - testInfo.skip(true, `AZURE_DB_*_HOST not set for ${config.name}`); - return; - } - test.setTimeout(600_000); await runtimeHarness.configureExternalPostgres({ credentials: { host: config.host, @@ -91,7 +84,6 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt password: azurePassword!, }, }); - expect(config.host).toBeTruthy(); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts new file mode 100644 index 0000000000..8bb2df8338 --- /dev/null +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-cloudsql.spec.ts @@ -0,0 +1,149 @@ +import { existsSync } from "fs"; + +import { test } from "@support/coverage/test"; + +import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; +import { HomePage } from "../../support/pages/home-page"; +import { resolveInstallMethod } from "../../utils/helper"; +import { clearDatabase, readCertificateFile } from "../../utils/postgres-config"; +import { ensureRuntimeDeployed } from "../../utils/runtime-deploy"; + +interface CloudSqlConfig { + name: string; + instanceConnectionName: string | undefined; + host: string | undefined; +} + +function isNonEmpty(value: string | undefined): value is string { + return value !== undefined && value !== ""; +} + +test.describe("Verify connection with Google Cloud SQL using Auth Proxy sidecar", () => { + const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; + const runtimeHarness = new RuntimeHarness(namespace); + + const cloudSqlUser = process.env.CLOUDSQL_USER; + const cloudSqlPassword = process.env.CLOUDSQL_PASSWORD; + const cloudSqlCertificatesPath = process.env.CLOUDSQL_DB_CERTIFICATES_PATH; + const serviceAccountJsonPath = + process.env.CLOUDSQL_SERVICE_ACCOUNT_JSON_PATH ?? "/tmp/secrets/cloudsql-service-account.json"; + + const cloudSqlConfigurations: CloudSqlConfig[] = [ + { + name: "latest-3", + instanceConnectionName: process.env.CLOUDSQL_INSTANCE_1, + host: process.env.CLOUDSQL_1_HOST, + }, + { + name: "latest-2", + instanceConnectionName: process.env.CLOUDSQL_INSTANCE_2, + host: process.env.CLOUDSQL_2_HOST, + }, + { + name: "latest-1", + instanceConnectionName: process.env.CLOUDSQL_INSTANCE_3, + host: process.env.CLOUDSQL_3_HOST, + }, + { + name: "latest", + instanceConnectionName: process.env.CLOUDSQL_INSTANCE_4, + host: process.env.CLOUDSQL_4_HOST, + }, + ]; + + test.beforeAll(async ({}, testInfo) => { + // Helm-only for now (RHIDP-9140). Operator nightly is broken / tracked separately (RHIDP-9141). + test.setTimeout(900_000); + test.info().annotations.push( + { + type: "component", + description: "data-management", + }, + { + type: "namespace", + description: namespace, + }, + ); + + if (resolveInstallMethod() !== "helm") { + testInfo.skip(true, "Cloud SQL Auth Proxy runtime coverage is Helm-only (RHIDP-9140)"); + return; + } + + await ensureRuntimeDeployed(); + + const cloudSqlCerts = readCertificateFile(cloudSqlCertificatesPath); + const firstInstance = cloudSqlConfigurations.find((c) => + isNonEmpty(c.instanceConnectionName), + )?.instanceConnectionName; + + if ( + cloudSqlCerts === null || + !isNonEmpty(cloudSqlUser) || + !isNonEmpty(cloudSqlPassword) || + !isNonEmpty(firstInstance) || + !existsSync(serviceAccountJsonPath) + ) { + testInfo.skip( + true, + "Cloud SQL environment variables not configured (CLOUDSQL_DB_CERTIFICATES_PATH, CLOUDSQL_USER, CLOUDSQL_PASSWORD, CLOUDSQL_INSTANCE_*, cloudsql-service-account.json) — Cloud SQL tests are opt-in", + ); + return; + } + + // App path uses Auth Proxy (localhost + ssl disable). CA PEM is for + // clearDatabase over the public host — same pattern as RDS/Azure. + console.log("Preparing Cloud SQL Auth Proxy sidecar..."); + await runtimeHarness.prepareForCloudSql({ + serviceAccountJsonPath, + initialInstanceConnectionName: firstInstance, + user: cloudSqlUser, + password: cloudSqlPassword, + }); + }); + + for (const config of cloudSqlConfigurations) { + // Configure lives in beforeAll so CI retries of Verify do not redeploy. + test.describe(`Cloud SQL ${config.name} PostgreSQL version`, () => { + test.beforeAll(async ({}, testInfo) => { + test.setTimeout(600_000); + if (!isNonEmpty(config.instanceConnectionName)) { + testInfo.skip(true, `CLOUDSQL_INSTANCE_* not set for ${config.name} — skipping`); + return; + } + if (!isNonEmpty(config.host)) { + testInfo.skip(true, `CLOUDSQL_*_HOST not set for ${config.name} — skipping`); + return; + } + if (!isNonEmpty(cloudSqlUser) || !isNonEmpty(cloudSqlPassword)) { + testInfo.skip(true, "CLOUDSQL_USER/PASSWORD not set"); + return; + } + test.info().annotations.push({ + type: "database", + description: config.instanceConnectionName.split(":")[2] || "unknown", + }); + + // Public-IP wipe with server CA — same clearDatabase contract as RDS/Azure. + await clearDatabase({ + host: config.host, + user: cloudSqlUser, + password: cloudSqlPassword, + certificatePath: cloudSqlCertificatesPath, + }); + + await runtimeHarness.configureCloudSqlInstance({ + instanceConnectionName: config.instanceConnectionName, + user: cloudSqlUser, + password: cloudSqlPassword, + }); + }); + + test("Verify successful DB connection", async ({ page }) => { + await runtimeHarness.verifyGuestSession(page); + const homePage = new HomePage(page); + await homePage.verifyWelcomeHeading(); + }); + }); + } +}); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts index a6881f82d1..8fd7385950 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; import { HomePage } from "../../support/pages/home-page"; @@ -59,9 +59,10 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => }); for (const config of rdsConfigurations) { - test.describe.serial(`RDS ${config.name} PostgreSQL version`, () => { + // Configure lives in beforeAll so CI retries of Verify do not redeploy. + test.describe(`RDS ${config.name} PostgreSQL version`, () => { test.beforeAll(async ({}, testInfo) => { - test.setTimeout(135_000); + test.setTimeout(600_000); if (config.host === undefined || config.host === "") { testInfo.skip(true, `RDS_*_HOST not set for ${config.name} — skipping`); return; @@ -76,14 +77,6 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => password: rdsPassword!, certificatePath: process.env.RDS_DB_CERTIFICATES_PATH, }); - }); - - test("Configure and restart deployment", async ({}, testInfo) => { - if (config.host === undefined || config.host === "") { - testInfo.skip(true, `RDS_*_HOST not set for ${config.name}`); - return; - } - test.setTimeout(600_000); await runtimeHarness.configureExternalPostgres({ credentials: { host: config.host, @@ -91,7 +84,6 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => password: rdsPassword!, }, }); - expect(config.host).toBeTruthy(); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts index 0ba9a57b5a..ed89f6b695 100644 --- a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts +++ b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts @@ -19,14 +19,23 @@ test.describe("Verify Redis Cache DB", () => { let redis: Redis; test.beforeAll(async () => { + const namespace = process.env.NAME_SPACE; + if (namespace === undefined || namespace === "") { + throw new Error("NAME_SPACE is required for Redis port-forward"); + } + console.log("Starting port-forward process..."); + // Spawn kubectl directly (no shell) so PortForwardSession process-group + // cleanup cannot leave an orphan occupying REDIS_LOCAL_PORT. portForward = new PortForwardHarness( { - shellCommand: ` - oc login --token="${process.env.K8S_CLUSTER_TOKEN}" --server="${process.env.K8S_CLUSTER_URL}" --insecure-skip-tls-verify=true && - kubectl config set-context --current --namespace="${process.env.NAME_SPACE}" && - kubectl port-forward service/redis ${REDIS_LOCAL_PORT}:6379 --namespace="${process.env.NAME_SPACE}" - `, + command: "kubectl", + args: [ + "port-forward", + `service/redis`, + `${REDIS_LOCAL_PORT}:6379`, + `--namespace=${namespace}`, + ], }, { readyPattern: /Forwarding from/u, diff --git a/e2e-tests/playwright/global-setup.ts b/e2e-tests/playwright/global-setup.ts index 83c4eb9566..726d0aacda 100644 --- a/e2e-tests/playwright/global-setup.ts +++ b/e2e-tests/playwright/global-setup.ts @@ -1,36 +1,5 @@ -import { request as playwrightRequest } from "@playwright/test"; +import { ensurePlaywrightReady } from "./utils/instance-readiness"; -import { ensureRuntimeDeployed } from "./utils/runtime-deploy"; -import { waitForRhdhReady } from "./utils/wait-for-rhdh-ready"; - -/** - * Ensures the deployed RHDH instance responds before any project runs. - * - * Deployment modes: - * - BASE_URL set → wait for that instance (CI or pre-deployed cluster) - * - BASE_URL unset + RUNTIME_AUTO_DEPLOY=true → deploy showcase-runtime, then wait - * - Otherwise → no-op (lint-only / legacy-local runs) - */ export default async function globalSetup(): Promise { - if ( - (process.env.BASE_URL === undefined || process.env.BASE_URL === "") && - process.env.RUNTIME_AUTO_DEPLOY === "true" - ) { - await ensureRuntimeDeployed(); - } - - const baseURL = process.env.BASE_URL; - if (baseURL === undefined || baseURL === "") { - return; - } - - const request = await playwrightRequest.newContext({ - baseURL, - ignoreHTTPSErrors: true, - }); - try { - await waitForRhdhReady(request); - } finally { - await request.dispose(); - } + await ensurePlaywrightReady(); } diff --git a/e2e-tests/playwright/support/auth/app-shell.ts b/e2e-tests/playwright/support/auth/app-shell.ts index 6bd3b8802c..436c6ab440 100644 --- a/e2e-tests/playwright/support/auth/app-shell.ts +++ b/e2e-tests/playwright/support/auth/app-shell.ts @@ -1,15 +1,38 @@ import { expect, type Page } from "@playwright/test"; -import { waitForRhdhReady, isJsonHealthcheckResponse } from "../../utils/wait-for-rhdh-ready"; +import { getGlobalHeader } from "../../utils/ui-helper/interaction"; +import { + isJsonHealthcheckResponse, + RHDH_READY_DEFAULT_TIMEOUT_MS, + waitForRhdhReady, +} from "../../utils/wait-for-rhdh-ready"; -const LOADING_INDICATOR_SELECTORS = [ - // Intentional divergence: MUI progress bars lack stable roles; class hooks are reliable in CI. +/** MUI class hooks — used by a11y scans; avoid role=progressbar there (persistent determinate bars). */ +const MUI_LOADING_SELECTORS = [ 'div[class*="MuiLinearProgress-root"]', '[class*="MuiCircularProgress-root"]', ] as const; -export async function waitForLoadingToSettle(page: Page, timeout = 120_000): Promise { - for (const selector of LOADING_INDICATOR_SELECTORS) { +/** + * Auth/app readiness also watches role=progressbar — CI stuck paints after reconcile + * showed only that landmark (no MUI class, no main). + */ +const APP_LOADING_SELECTORS = [...MUI_LOADING_SELECTORS, '[role="progressbar"]'] as const; + +/** Brief window for post-reconcile hydrate to mount a loader before settle no-ops. */ +const LOADER_APPEAR_BUDGET_MS = 5_000; + +async function settleLoaders( + page: Page, + selectors: readonly string[], + timeout: number, +): Promise { + const loaders = page.locator(selectors.join(", ")); + await loaders + .first() + .waitFor({ state: "visible", timeout: Math.min(LOADER_APPEAR_BUDGET_MS, timeout) }) + .catch(() => {}); + for (const selector of selectors) { const indicator = page.locator(selector).first(); const visible = await indicator.isVisible().catch(() => false); if (visible) { @@ -18,6 +41,14 @@ export async function waitForLoadingToSettle(page: Page, timeout = 120_000): Pro } } +/** Wait for MUI loaders only (a11y / generic). Prefer waitForAppReady for auth shells. */ +export async function waitForLoadingToSettle( + page: Page, + timeout = RHDH_READY_DEFAULT_TIMEOUT_MS, +): Promise { + await settleLoaders(page, MUI_LOADING_SELECTORS, timeout); +} + export async function hasJsonHealthcheck(page: Page): Promise { const response = await page.request.get("/healthcheck").catch(() => null); if (response === null) { @@ -27,11 +58,73 @@ export async function hasJsonHealthcheck(page: Page): Promise { return isJsonHealthcheckResponse(response.status(), contentType); } -export async function waitForAppReady(page: Page, timeout = 120_000): Promise { +export async function waitForAppReady( + page: Page, + timeout = RHDH_READY_DEFAULT_TIMEOUT_MS, +): Promise { // Cluster-free legacy harness serves the SPA on BASE_URL; backend readiness is // enforced by webServer startup instead of a JSON /healthcheck on the frontend. if (await hasJsonHealthcheck(page)) { await waitForRhdhReady(page.request, timeout); } - await waitForLoadingToSettle(page, timeout); + // Include role=progressbar so stuck post-reconcile paints fail here, not later + // on provider-card expects after waitForAppReady returned green. + await settleLoaders(page, APP_LOADING_SELECTORS, timeout); +} + +/** + * Post-login readiness: OAuth popup close is not "authenticated shell". + * Wait until the global header (profile dropdown) is visible — the same + * signal Settings POM navigation depends on. + */ +export async function waitForAuthenticatedShell( + page: Page, + timeout = RHDH_READY_DEFAULT_TIMEOUT_MS, +): Promise { + await waitForAppReady(page, timeout); + await expect(getGlobalHeader(page)).toBeVisible({ timeout }); +} + +/** + * After an OAuth popup closes, either the authenticated shell appears (success) + * or a sign-in alert appears (expected identity-resolution failures). + * + * Do not stack a full /healthcheck budget here — reconcile/deploy already proved + * HTTP. Racing header vs alert is the login locality signal. + */ +export type LoginOutcome = "authenticated" | "error"; + +const POPUP_SUCCESS_STATUSES = new Set(["Login successful", "Already logged in"]); + +export function isPopupLoginSuccess(popupStatus: string): boolean { + return POPUP_SUCCESS_STATUSES.has(popupStatus); +} + +export async function waitForLoginOutcome( + page: Page, + timeout = RHDH_READY_DEFAULT_TIMEOUT_MS, +): Promise { + const header = getGlobalHeader(page); + const alert = page.getByRole("alert"); + let outcome: LoginOutcome | "pending" = "pending"; + await expect + .poll( + async () => { + if (await header.isVisible().catch(() => false)) { + outcome = "authenticated"; + return "authenticated"; + } + if (await alert.isVisible().catch(() => false)) { + outcome = "error"; + return "error"; + } + return "pending"; + }, + { timeout, intervals: [500, 1000, 2000] }, + ) + .not.toBe("pending"); + if (outcome === "pending") { + throw new Error("Login outcome stayed pending after waitForLoginOutcome"); + } + return outcome; } diff --git a/e2e-tests/playwright/support/auth/provider-auth.ts b/e2e-tests/playwright/support/auth/provider-auth.ts index b56a7e2ba6..cb254de2fd 100644 --- a/e2e-tests/playwright/support/auth/provider-auth.ts +++ b/e2e-tests/playwright/support/auth/provider-auth.ts @@ -8,30 +8,94 @@ import { handleMicrosoftAzurePopupLogin, handlePingFederatePopupLogin, } from "../../utils/common/auth-popup"; +import { sleep } from "../../utils/poll-until"; import * as interaction from "../../utils/ui-helper/interaction"; -import { waitForAppReady } from "./app-shell"; +import { RHDH_READY_DEFAULT_TIMEOUT_MS } from "../../utils/wait-for-rhdh-ready"; +import { + waitForAppReady, + waitForLoginOutcome, + isPopupLoginSuccess, + type LoginOutcome, +} from "./app-shell"; const t = getTranslations(); +/** + * Connection drops after pod restart / route flip. + * ERR_ABORTED only when it is a page.goto navigation abort — not arbitrary AbortErrors. + */ +export function isRetryableConnectionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("ERR_CONNECTION_REFUSED") || + message.includes("ERR_CONNECTION_RESET") || + message.includes("ERR_CONNECTION_CLOSED") || + message.includes("ERR_EMPTY_RESPONSE") || + message.includes("ERR_NETWORK_CHANGED") + ) { + return true; + } + // Chromium aborts in-flight navigations when the pod restarts mid-goto after reconcile. + return message.includes("ERR_ABORTED") && /page\.goto|navigating to/iu.test(message); +} + export class AuthProviderSession { constructor( private readonly page: Page, private readonly locale?: Locale, + private readonly baseURL?: string, ) {} private lang(): Locale { return this.locale ?? getCurrentLanguage(); } + /** Prefer absolute URLs when baseURL is known so relative goto works without context baseURL. */ + private resolveUrl(path: string): string { + if (this.baseURL === undefined || this.baseURL === "") { + return path; + } + const base = this.baseURL.endsWith("/") ? this.baseURL : `${this.baseURL}/`; + return new URL(path, base).href; + } + async clearAuthState(context: BrowserContext): Promise { await context.clearCookies(); await context.clearPermissions(); } + /** Retry only hard connection failures (e.g. brief post-reconcile downtime). */ + private async gotoWithRetry(url: string, attempts = 3): Promise { + let lastError: Error | undefined; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await this.page.goto(url, { waitUntil: "domcontentloaded" }); + return; + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + lastError = normalized; + if (!isRetryableConnectionError(error) || attempt === attempts) { + throw normalized; + } + console.log( + `[INFO] Connection error on attempt ${attempt}/${attempts} for ${url}, retrying: ${normalized.message}`, + ); + // Clear half-loaded documents so the next goto does not inherit a stuck paint. + await this.page.goto("about:blank").catch(() => {}); + await sleep(2_000 * attempt); + } + } + throw lastError ?? new Error(`Failed to navigate to ${url}`); + } + private async openLandingPageWithProviderMessage(message: string): Promise { - await this.page.goto("/"); + await this.gotoWithRetry(this.resolveUrl("/")); await waitForAppReady(this.page); - await expect(this.page.getByRole("main").getByText(message)).toBeVisible(); + // Post-reconcile SPA can still be hydrating after /healthcheck is OK — + // give the provider card the same budget as app readiness, not expect's 10s default. + await expect(this.page.getByRole("main").getByText(message)).toBeVisible({ + timeout: RHDH_READY_DEFAULT_TIMEOUT_MS, + }); } private async openPrimarySignInPopup(): Promise { @@ -43,69 +107,80 @@ export class AuthProviderSession { return popup; } - async loginWithKeycloak(username: string, password: string): Promise { + private async finishLogin(popupResult: Promise): Promise { + const popupStatus = await popupResult; + // IdP rejection: still race the app shell briefly so alerts can win; fail closed on timeout. + const timeoutMs = isPopupLoginSuccess(popupStatus) ? RHDH_READY_DEFAULT_TIMEOUT_MS : 15_000; + return waitForLoginOutcome(this.page, timeoutMs); + } + + async loginWithKeycloak(username: string, password: string): Promise { const lang = this.lang(); await this.openLandingPageWithProviderMessage(t["rhdh"][lang]["signIn.providers.oidc.message"]); const popup = await this.openPrimarySignInPopup(); - return handleKeycloakPopupLogin(popup, username, password); + return this.finishLogin(handleKeycloakPopupLogin(popup, username, password)); } - async loginWithGitHub(username: string, password: string, twofactor: string): Promise { + async loginWithGitHub( + username: string, + password: string, + twofactor: string, + ): Promise { const lang = this.lang(); await this.openLandingPageWithProviderMessage( t["rhdh"][lang]["signIn.providers.github.message"], ); const popup = await this.openPrimarySignInPopup(); - return handleGitHubPopupLogin(popup, username, password, twofactor); + return this.finishLogin(handleGitHubPopupLogin(popup, username, password, twofactor)); } async loginWithGitHubFromSettingsPage( username: string, password: string, twofactor: string, - ): Promise { + ): Promise { const lang = this.lang(); - await this.page.goto("/settings/auth-providers"); + await this.gotoWithRetry(this.resolveUrl("/settings/auth-providers")); + await waitForAppReady(this.page); + + const signInTitle = t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( + "{{title}}", + "GitHub", + ); + const githubSignIn = this.page.getByTitle(signInTitle); + await expect(githubSignIn).toBeVisible({ timeout: 30_000 }); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), - this.page - // Intentional divergence: provider settings expose sign-in via title tooltip, not button role. - .getByTitle( - t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( - "{{title}}", - "GitHub", - ), - ) - .click(), + githubSignIn.click(), interaction.clickButton(this.page, t["core-components"][lang]["oauthRequestDialog.login"]), ]); - return handleGitHubPopupLogin(popup, username, password, twofactor); + return this.finishLogin(handleGitHubPopupLogin(popup, username, password, twofactor)); } - async loginWithGitLab(username: string, password: string): Promise { + async loginWithGitLab(username: string, password: string): Promise { const lang = this.lang(); await this.openLandingPageWithProviderMessage( t["rhdh"][lang]["signIn.providers.gitlab.message"], ); const popup = await this.openPrimarySignInPopup(); - return handleGitlabPopupLogin(popup, username, password); + return this.finishLogin(handleGitlabPopupLogin(popup, username, password)); } - async loginWithMicrosoftAzure(username: string, password: string): Promise { + async loginWithMicrosoftAzure(username: string, password: string): Promise { const lang = this.lang(); await this.openLandingPageWithProviderMessage( t["rhdh"][lang]["signIn.providers.microsoft.message"], ); const popup = await this.openPrimarySignInPopup(); - return handleMicrosoftAzurePopupLogin(popup, username, password); + return this.finishLogin(handleMicrosoftAzurePopupLogin(popup, username, password)); } - async loginWithPingFederate(username: string, password: string): Promise { + async loginWithPingFederate(username: string, password: string): Promise { const lang = this.lang(); await this.openLandingPageWithProviderMessage(t["rhdh"][lang]["signIn.providers.oidc.message"]); const popup = await this.openPrimarySignInPopup(); - return handlePingFederatePopupLogin(popup, username, password); + return this.finishLogin(handlePingFederatePopupLogin(popup, username, password)); } } diff --git a/e2e-tests/playwright/support/coverage/test.ts b/e2e-tests/playwright/support/coverage/test.ts index b9afb21f24..1e31355bef 100644 --- a/e2e-tests/playwright/support/coverage/test.ts +++ b/e2e-tests/playwright/support/coverage/test.ts @@ -11,6 +11,7 @@ // // For serial specs that share one browser context across a describe block, // use the worker-scoped fixtures instead of manual beforeAll setup: +// useRhdhBaseURL(instanceUrl); // file top-level — or createAuthProviderHarness() // test.beforeAll(async ({ rhdhPage, rhdhContext }) => { ... }); // // For specs that create their own context/page via browser.newContext(), @@ -46,6 +47,8 @@ type RhdhBrowserWorkerFixtures = { rhdhPage: Page; rhdhGuestPage: Page; rhdhAuthSession: AuthProviderSession; + // Worker-scoped mirror of baseURL for rhdhContext (test.use({ baseURL }) is test-scoped only). + workerBaseURL: string | undefined; }; type RhdhPerTestFixtures = { @@ -53,6 +56,20 @@ type RhdhPerTestFixtures = { authSession: AuthProviderSession; }; +function resolveWorkerBaseURL( + workerBaseURL: string | undefined, + projectBaseURL: string | undefined, +): string | undefined { + // Treat "" like unset — auth CI intentionally exports empty BASE_URL. + if (workerBaseURL !== undefined && workerBaseURL !== "") { + return workerBaseURL; + } + if (projectBaseURL !== undefined && projectBaseURL !== "") { + return projectBaseURL; + } + return undefined; +} + // eslint-disable-next-line @typescript-eslint/naming-convention export const test = baseTest.extend( { @@ -67,14 +84,18 @@ export const test = baseTest.extend { + authSession: async ({ page, locale, baseURL }, use) => { const { AuthProviderSession } = await import("../auth/provider-auth"); const { resolveLocale } = await import("../../e2e/localization/locale"); - await use(new AuthProviderSession(page, resolveLocale(locale))); + await use(new AuthProviderSession(page, resolveLocale(locale), baseURL)); }, + // Default undefined → fall back to project `use.baseURL` (process.env.BASE_URL). + // Must be set via file top-level test.use / useRhdhBaseURL — not inside describe. + workerBaseURL: [undefined, { option: true, scope: "worker" }], rhdhContext: [ - async ({ browser }, use, workerInfo: WorkerInfo) => { - const { baseURL, locale, ignoreHTTPSErrors } = workerInfo.project.use; + async ({ browser, workerBaseURL }, use, workerInfo: WorkerInfo) => { + const { baseURL: projectBaseURL, locale, ignoreHTTPSErrors } = workerInfo.project.use; + const baseURL = resolveWorkerBaseURL(workerBaseURL, projectBaseURL); const session = await createBrowserSession(browser, { baseURL, locale, @@ -106,11 +127,12 @@ export const test = baseTest.extend { + async ({ rhdhPage, workerBaseURL }, use, workerInfo: WorkerInfo) => { const { AuthProviderSession } = await import("../auth/provider-auth"); const { resolveLocale } = await import("../../e2e/localization/locale"); + const baseURL = resolveWorkerBaseURL(workerBaseURL, workerInfo.project.use.baseURL); await use( - new AuthProviderSession(rhdhPage, resolveLocale(workerInfo.project.use.locale)), + new AuthProviderSession(rhdhPage, resolveLocale(workerInfo.project.use.locale), baseURL), ); }, { scope: "worker" }, @@ -118,6 +140,15 @@ export const test = baseTest.extend; - extraSecrets?: Record | (() => Record); - beforeSecrets?: () => Promise; - beforeDeploy?: () => Promise; - enableProvider: (deployment: RHDHDeployment) => Promise; -}; +/** Short probe — only decides wipe vs reuse, not deploy readiness. */ +const REUSE_HEALTHCHECK_TIMEOUT_MS = 20_000; type AuthLoginCase = { configure?: () => Promise; - login: () => Promise; + login: () => Promise; assert: () => Promise; cleanup?: () => Promise; - expectedResult?: string; + expectedResult?: LoginOutcome; }; -/** Shared K8s + RHDH deployment orchestration for auth-provider E2E specs. */ +/** Deploy/config glue for auth-provider E2E specs. For Playwright wiring use createAuthProviderHarness. */ export class AuthProviderHarness { readonly deployment: RHDHDeployment; readonly backstageUrl: string; @@ -63,12 +65,39 @@ export class AuthProviderHarness { } } - async loadConfigsAndProvisionNamespace(): Promise { + /** + * Prefer reusing a healthy remote instance after Playwright worker restarts + * (flake retries). Wipe only when forced, local, or healthcheck fails. + */ + async loadConfigsAndProvisionNamespace(): Promise { await this.deployment.loadAllConfigs(); + if (await this.canReuseHealthyRemoteInstance()) { + await this.deployment.generateStaticToken(); + console.log( + "[INFO] Reusing healthy auth namespace — skip wipe (set FORCE_AUTH_REDEPLOY=1 to force)", + ); + return "reused"; + } await this.deployment.deleteNamespaceIfExists(); await (await this.deployment.createNamespace()).waitForNamespaceActive(); await this.deployment.createAllConfigs(); await this.deployment.generateStaticToken(); + return "fresh"; + } + + private async canReuseHealthyRemoteInstance(): Promise { + if (this.deployment.isRunningLocal) { + return false; + } + if (process.env.FORCE_AUTH_REDEPLOY === "1") { + return false; + } + try { + await healthcheckRhdhAtUrl(this.backstageUrl, REUSE_HEALTHCHECK_TIMEOUT_MS); + return true; + } catch { + return false; + } } async addBaseUrlSecretsIfRemote(): Promise { @@ -92,42 +121,24 @@ export class AuthProviderHarness { await this.deployment.createSecret(); } - async deployAndWait(): Promise { - await this.deployment.createBackstageDeployment(); - await this.deployment.waitForDeploymentReady(); - await this.deployment.waitForSynced(); - } - - async prepareProvider(options: PrepareAuthProviderOptions): Promise { - this.expectEnvVars(options.requiredEnvVars); - await this.loadConfigsAndProvisionNamespace(); - await options.beforeSecrets?.(); - await this.addBaseUrlSecretsIfRemote(); - - if (options.envSecrets !== undefined) { - await this.addSecretsFromEnv(options.envSecrets); - } - const extraSecrets = - typeof options.extraSecrets === "function" ? options.extraSecrets() : options.extraSecrets; - if (extraSecrets !== undefined) { - for (const [key, value] of Object.entries(extraSecrets)) { - await this.deployment.addSecretData(key, value); - } - } - - await this.createSecret(); - await options.enableProvider(this.deployment); - await this.deployment.updateAllConfigs(); - await options.beforeDeploy?.(); - await this.deployAndWait(); + async prepareProvider(options: { + requiredEnvVars: string[]; + envSecrets?: Record; + extraSecrets?: Record | (() => Record); + beforeSecrets?: () => Promise; + beforeDeploy?: () => Promise; + enableProvider: (deployment: RHDHDeployment) => Promise; + }): Promise { + await deployAuthInstance(this, { + ...options, + enableProvider: async () => { + await options.enableProvider(this.deployment); + }, + }); } - async reconcileAfterConfigChange(): Promise { - await this.deployment.updateAllConfigs(); - await this.deployment.restartLocalDeployment(); - await this.deployment.waitForConfigReconciled(); - await this.deployment.waitForDeploymentReady(); - await this.deployment.waitForSynced(); + async reconcileAfterConfigChange(options?: ReconcileAuthInstanceOptions): Promise { + await reconcileAuthInstance(this, options); } async runLoginCase(options: AuthLoginCase): Promise { @@ -136,7 +147,7 @@ export class AuthProviderHarness { await options.configure(); } const result = await options.login(); - expect(result).toBe(options.expectedResult ?? "Login successful"); + expect(result).toBe(options.expectedResult ?? "authenticated"); await options.assert(); } finally { await options.cleanup?.(); diff --git a/e2e-tests/playwright/support/fixtures/auth-provider-playwright.ts b/e2e-tests/playwright/support/fixtures/auth-provider-playwright.ts new file mode 100644 index 0000000000..718a92d5e5 --- /dev/null +++ b/e2e-tests/playwright/support/fixtures/auth-provider-playwright.ts @@ -0,0 +1,18 @@ +import { useRhdhBaseURL } from "../coverage/test"; +import { AuthProviderHarness } from "./auth-provider-harness"; + +/** + * Create an auth-provider harness and bind its URL into Playwright + * (`baseURL` + worker-scoped `workerBaseURL`). + * + * Must be called at file top level — Playwright rejects worker-scoped + * `test.use` inside `describe` / hooks. + */ +export function createAuthProviderHarness( + namespace: string, + instanceName = "rhdh", +): AuthProviderHarness { + const harness = AuthProviderHarness.create(namespace, instanceName); + useRhdhBaseURL(harness.backstageUrl); + return harness; +} diff --git a/e2e-tests/playwright/support/harnesses/runtime-harness.ts b/e2e-tests/playwright/support/harnesses/runtime-harness.ts index 523aa12eff..68f19560aa 100644 --- a/e2e-tests/playwright/support/harnesses/runtime-harness.ts +++ b/e2e-tests/playwright/support/harnesses/runtime-harness.ts @@ -1,5 +1,10 @@ import { type Page } from "@playwright/test"; +import { + configureCloudSqlProxyInstance, + createCloudSqlServiceAccountSecret, + injectCloudSqlSidecar, +} from "../../utils/cloudsql-config"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { pollUntil } from "../../utils/poll-until"; import { @@ -26,6 +31,7 @@ export class RuntimeHarness { private readonly namespace: string, private readonly deploymentName: string = getRhdhDeploymentName(), private readonly kubeClient: KubeClient = new KubeClient(), + private readonly releaseName: string = process.env.RELEASE_NAME ?? "rhdh", ) {} async updateConfigMapTitle(configMapName: string, title: string): Promise { @@ -86,11 +92,65 @@ export class RuntimeHarness { await prepareForExternalDatabase(this.kubeClient, this.namespace, this.deploymentName); } + /** + * Prepare runtime for Google Cloud SQL via Auth Proxy sidecar. + * Creates the SA secret, patches app-config, sets localhost credentials, and + * injects the proxy for the first instance connection name. + */ + async prepareForCloudSql(options: { + serviceAccountJsonPath: string; + initialInstanceConnectionName: string; + user: string; + password: string; + }): Promise { + await createCloudSqlServiceAccountSecret( + this.kubeClient, + this.namespace, + options.serviceAccountJsonPath, + ); + await this.prepareForExternalDatabase(); + // Credentials must be valid before sidecar inject waits on Deployment ready. + await this.configurePostgresCredentials({ + host: "127.0.0.1", + user: options.user, + password: options.password, + sslMode: "disable", + }); + await injectCloudSqlSidecar( + this.kubeClient, + this.namespace, + this.releaseName, + options.initialInstanceConnectionName, + ); + } + + /** + * Point the Auth Proxy at a Cloud SQL instance and set app credentials for + * localhost (proxy) with SSL disabled. One restart waits for the proxy + * startupProbe before RHDH becomes Ready. + */ + async configureCloudSqlInstance(options: { + instanceConnectionName: string; + user: string; + password: string; + }): Promise { + await this.configurePostgresCredentials({ + host: "127.0.0.1", + user: options.user, + password: options.password, + sslMode: "disable", + }); + await configureCloudSqlProxyInstance( + this.kubeClient, + this.namespace, + this.releaseName, + options.instanceConnectionName, + ); + } + /** Clear session state and sign in as guest after a deployment restart. */ async verifyGuestSession(page: Page): Promise { await page.context().clearCookies(); - await page.context().clearPermissions(); - await page.reload({ waitUntil: "domcontentloaded" }); await signInAsGuest(page); } } diff --git a/e2e-tests/playwright/support/pages/settings-page.ts b/e2e-tests/playwright/support/pages/settings-page.ts index 0b5bdd4024..4a53bbb181 100644 --- a/e2e-tests/playwright/support/pages/settings-page.ts +++ b/e2e-tests/playwright/support/pages/settings-page.ts @@ -164,6 +164,17 @@ export class SettingsPage { await SETTINGS_PAGE_COMPONENTS.getSignOut(this.page).click(); } + /** Sign out a secondary OAuth provider from Settings → Authentication Providers. */ + async signOutFromAuthProvider(providerTitle: string): Promise { + const lang = getCurrentLanguage(); + const title = t["user-settings"][lang]["providerSettingsItem.title.signOut"].replace( + "{{title}}", + providerTitle, + ); + // Intentional divergence: provider settings expose sign-out via title tooltip. + await this.page.getByTitle(title).click(); + } + async closeUserSettingsMenu(): Promise { await this.page.keyboard.press("Escape"); } diff --git a/e2e-tests/playwright/utils/api-helper/guards.ts b/e2e-tests/playwright/utils/api-helper/guards.ts index 3a914e6dee..26f0ee0c67 100644 --- a/e2e-tests/playwright/utils/api-helper/guards.ts +++ b/e2e-tests/playwright/utils/api-helper/guards.ts @@ -1,5 +1,13 @@ import { type UserEntity } from "@backstage/catalog-model"; -import { type APIResponse } from "@playwright/test"; + +/** Minimal surface of Playwright APIResponse used by parseJsonResponse. */ +export interface JsonHttpResponse { + ok(): boolean; + status(): number; + url(): string; + text(): Promise; + json(): Promise; +} interface GitHubPullRequestFile { filename: string; @@ -42,7 +50,17 @@ export function isUserEntity(value: unknown): value is UserEntity { return isEntityMetadataResponse(value) && "kind" in value && value.kind === "User"; } -export function parseJsonResponse(response: APIResponse): Promise { +/** + * Parse JSON from a successful response. Non-2xx fails immediately so callers + * do not treat auth/proxy errors as "not ingested yet". + */ +export async function parseJsonResponse(response: JsonHttpResponse): Promise { + if (!response.ok()) { + const body = await response.text().catch(() => ""); + throw new Error( + `HTTP ${String(response.status())} for ${response.url()}: ${body.slice(0, 200)}`, + ); + } return response.json(); } diff --git a/e2e-tests/playwright/utils/api-helper/index.ts b/e2e-tests/playwright/utils/api-helper/index.ts index 23fee5783b..d1503fc169 100644 --- a/e2e-tests/playwright/utils/api-helper/index.ts +++ b/e2e-tests/playwright/utils/api-helper/index.ts @@ -5,6 +5,15 @@ import * as catalogApi from "./catalog"; import * as githubApi from "./github"; import { isUserEntity, parseJsonResponse } from "./guards"; +/** + * Shared Playwright APIRequest timeout. Default is 10s; RHDH through OpenShift + * Routes often needs longer for catalog and other authenticated fetches. + */ +export const API_REQUEST_TIMEOUT_MS = 60_000; + +/** Alias of API_REQUEST_TIMEOUT_MS for catalog-oriented call sites. */ +export const CATALOG_API_TIMEOUT_MS = API_REQUEST_TIMEOUT_MS; + export class APIHelper { private staticToken = ""; private baseUrl = ""; @@ -32,8 +41,9 @@ export class APIHelper { url: string, staticToken: string, body?: string | object, + timeoutMs: number = API_REQUEST_TIMEOUT_MS, ): Promise { - const context = await request.newContext(); + const context = await request.newContext({ timeout: timeoutMs }); const options: { method: string; headers: { @@ -41,12 +51,14 @@ export class APIHelper { Authorization: string; }; data?: string | object; + timeout: number; } = { method: method, headers: { Accept: "application/json", Authorization: staticToken, }, + timeout: timeoutMs, }; if (body !== undefined) { @@ -73,16 +85,34 @@ export class APIHelper { return parseJsonResponse(response); } + private static jsonOrNullOn404(response: APIResponse): Promise { + if (response.status() === 404) { + return Promise.resolve(null); + } + return parseJsonResponse(response); + } + + /** + * Fetch a group by name. Returns null on 404 so ingestion polls can + * wait for the entity; other non-2xx and malformed 200s fail fast. + */ async getGroupEntityFromAPI(group: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); - return parseJsonResponse(response); + return APIHelper.jsonOrNullOn404(response); } - async getCatalogUserFromAPI(user: string): Promise { + /** + * Fetch a user by name. Returns null on 404 so annotation polls can wait; + * other non-2xx and malformed 200s fail fast. + */ + async getCatalogUserFromAPI(user: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); - const body: unknown = await parseJsonResponse(response); + const body = await APIHelper.jsonOrNullOn404(response); + if (body === null) { + return null; + } if (!isUserEntity(body)) { throw new TypeError(`Invalid catalog user response for ${user}`); } diff --git a/e2e-tests/playwright/utils/authentication-providers/auth-cookie-duration.ts b/e2e-tests/playwright/utils/authentication-providers/auth-cookie-duration.ts new file mode 100644 index 0000000000..d9c4ad68cd --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/auth-cookie-duration.ts @@ -0,0 +1,49 @@ +/** + * Shared refresh-token cookie duration assertions for auth-provider specs. + */ + +import { expect, type BrowserContext } from "@playwright/test"; + +export const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; +export const DEFAULT_COOKIE_TOLERANCE_MS = 3 * 60 * 1000; + +/** Remaining lifetime of a Playwright cookie from a fixed `now` (ms). */ +export function refreshTokenRemainingMs( + cookie: { expires: number }, + nowMs: number = Date.now(), +): number { + return cookie.expires * 1000 - nowMs; +} + +export function isRefreshTokenDurationNear( + cookie: { expires: number } | undefined, + expectedMs: number, + toleranceMs: number = DEFAULT_COOKIE_TOLERANCE_MS, + nowMs: number = Date.now(), +): boolean { + if (cookie === undefined) { + return false; + } + const actual = refreshTokenRemainingMs(cookie, nowMs); + return actual > expectedMs - toleranceMs && actual < expectedMs + toleranceMs; +} + +/** + * Idle logout can clear UI before the httpOnly refresh cookie disappears. + * Poll until the named cookie is gone instead of a one-shot expect. + */ +export async function waitForNamedCookieAbsent( + context: BrowserContext, + cookieName: string, + timeoutMs = 30_000, +): Promise { + await expect + .poll( + async () => { + const cookies = await context.cookies(); + return cookies.some((cookie) => cookie.name === cookieName) ? "present" : "absent"; + }, + { timeout: timeoutMs, intervals: [500, 1000, 2000] }, + ) + .toBe("absent"); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/auth-instance-deployer.ts b/e2e-tests/playwright/utils/authentication-providers/auth-instance-deployer.ts new file mode 100644 index 0000000000..3a33ac1af9 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/auth-instance-deployer.ts @@ -0,0 +1,151 @@ +/** + * Auth instance deployer — deep module over prepare/deploy/reconcile. + * + * Specs and the Playwright harness call this interface; K8s/YAML/wait details + * stay behind the seam. + */ + +import { waitForDeploymentReadiness } from "../deployment-readiness"; +import { RHDH_READY_DEPLOY_TIMEOUT_MS, healthcheckRhdhAtUrl } from "../wait-for-rhdh-ready"; + +/** Minimal deployment surface the deployer needs (satisfied by RHDHDeployment). */ +export type AuthDeploymentPort = { + isRunningLocal: boolean; + addSecretData: (key: string, value: string) => Promise; + setAppConfigProperty: (path: string, value: unknown) => unknown; + updateAllConfigs: () => Promise; + createBackstageDeployment: (options?: { waitForReady?: boolean }) => Promise; + waitForDeploymentCreated: () => Promise; + waitForSynced: () => Promise; + /** Persist → force restart → prove marker in mounted config (remote). */ + waitUntilAuthConfigLive: (configMarker: string) => Promise; + restartLocalDeployment: () => Promise; +}; + +export type AuthInstanceDeployerOptions = { + requiredEnvVars: string[]; + envSecrets?: Record; + extraSecrets?: Record | (() => Record); + beforeSecrets?: () => Promise; + beforeDeploy?: () => Promise; + enableProvider: (deployment: AuthDeploymentPort) => Promise; +}; + +export type AuthInstanceDeployResult = { + url: string; +}; + +export type ReconcileAuthInstanceOptions = { + /** When true, also wait for catalog sync after HTTP. Default false (auth-only). */ + waitForCatalogSync?: boolean; +}; + +export type AuthNamespaceProvision = "fresh" | "reused"; + +export type AuthInstanceDeployerHost = { + deployment: AuthDeploymentPort; + backstageUrl: string; + backstageBackendUrl: string; + expectEnvVars: (envVarNames: string[]) => void; + /** + * Load file configs and either wipe+provision a new namespace (`fresh`) or + * keep a healthy existing one (`reused`) so worker restarts after flakes do + * not pay full CR recreate cost. + */ + loadConfigsAndProvisionNamespace: () => Promise; + addBaseUrlSecretsIfRemote: () => Promise; + addSecretsFromEnv: (entries: Record) => Promise; + createSecret: () => Promise; +}; + +function readinessDeps(host: AuthInstanceDeployerHost) { + return { + waitForCreated: async () => { + await host.deployment.waitForDeploymentCreated(); + }, + waitForHttpReady: async () => { + if (host.deployment.isRunningLocal) { + return; + } + await healthcheckRhdhAtUrl(host.backstageUrl, RHDH_READY_DEPLOY_TIMEOUT_MS); + }, + waitForSynced: async () => { + await host.deployment.waitForSynced(); + }, + }; +} + +function newAuthConfigMarker(): string { + return `e2e-auth-config-${String(Date.now())}`; +} + +/** + * Deploy an auth-provider RHDH instance and wait created → HTTP → synced. + * + * When the namespace is reused (healthy leftover from a prior worker), still + * re-apply secrets/CR/provider baseline, then reconcile with catalog sync + * instead of deleting the namespace — preserves IdP redirect registrations + * while resetting resolver state. + * + * BACKEND_SECRET comes only from the CR extraEnvs (OperatorInstallProfile) — + * do not also put it in rhdh-secrets or the operator emits a duplicate env. + */ +export async function deployAuthInstance( + host: AuthInstanceDeployerHost, + options: AuthInstanceDeployerOptions, +): Promise { + host.expectEnvVars(options.requiredEnvVars); + const provision = await host.loadConfigsAndProvisionNamespace(); + + await options.beforeSecrets?.(); + await host.addBaseUrlSecretsIfRemote(); + + if (options.envSecrets !== undefined) { + await host.addSecretsFromEnv(options.envSecrets); + } + const extraSecrets = + typeof options.extraSecrets === "function" ? options.extraSecrets() : options.extraSecrets; + if (extraSecrets !== undefined) { + for (const [key, value] of Object.entries(extraSecrets)) { + await host.deployment.addSecretData(key, value); + } + } + + await host.createSecret(); + await options.enableProvider(host.deployment); + await host.deployment.updateAllConfigs(); + await options.beforeDeploy?.(); + await host.deployment.createBackstageDeployment({ waitForReady: false }); + + if (provision === "reused") { + // Namespace/CR already existed (worker retry). Re-apply baseline above, then + // force process restart + catalog sync so leftover resolver state cannot leak. + await reconcileAuthInstance(host, { waitForCatalogSync: true }); + return { url: host.backstageUrl }; + } + + await waitForDeploymentReadiness(["created", "http", "synced"], readinessDeps(host)); + return { url: host.backstageUrl }; +} + +/** + * Reconcile after in-place config changes. + * + * Auth settings are process-start only: stamp a title marker, persist ConfigMaps, + * force a workload restart, prove the marker is on the mounted config, then HTTP. + * Catalog sync is opt-in — most auth-only mutations do not need it. + */ +export async function reconcileAuthInstance( + host: AuthInstanceDeployerHost, + options: ReconcileAuthInstanceOptions = {}, +): Promise { + const marker = newAuthConfigMarker(); + host.deployment.setAppConfigProperty("app.title", marker); + await host.deployment.updateAllConfigs(); + await host.deployment.restartLocalDeployment(); + await host.deployment.waitUntilAuthConfigLive(marker); + await waitForDeploymentReadiness(["http"], readinessDeps(host)); + if (options.waitForCatalogSync === true) { + await waitForDeploymentReadiness(["synced"], readinessDeps(host)); + } +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts index 7a7a0e1d4b..d927965e06 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts @@ -1,11 +1,14 @@ import { expect } from "@playwright/test"; import * as yaml from "yaml"; +import { setPluginEnabled } from "../../dynamic-plugins-profile"; import { RHDHDeploymentState } from "./types"; export interface AuthConfigActions { setDynamicPluginEnabled(pluginName: string, enabled: boolean): void; setAppConfigProperty(path: string, value: unknown): void; + /** Remove a previously set path so leftover keys cannot leak across login cases. */ + deleteAppConfigProperty(path: string): void; } const OIDC_CALLBACK_URL = "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame"; @@ -331,6 +334,58 @@ export function setGithubResolver( ]); } +/** + * Login cases that are not resolver experiments must pin a known-good resolver + * together with any other auth mutation (e.g. sessionDuration). Otherwise an + * earlier test's resolver leaks across the shared namespace and breaks login. + */ +export function configureGithubSessionDuration( + actions: AuthConfigActions, + sessionDuration: string, +): void { + setGithubResolver(actions, "usernameMatchingUserEntityName", false); + actions.setAppConfigProperty("auth.providers.github.production.sessionDuration", sessionDuration); +} + +export function configureOidcSessionDuration( + actions: AuthConfigActions, + sessionDuration: string, +): void { + // Prefer emailMatching + Zeus over preferredUsername + Atena: email is stable + // across Keycloak profiles and does not depend on the patched preferredUsername resolver. + setOIDCResolver(actions, "emailMatchingUserEntityProfileEmail", false); + actions.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", sessionDuration); +} + +export function configureMicrosoftSessionDuration( + actions: AuthConfigActions, + sessionDuration: string, +): void { + setMicrosoftResolver(actions, "emailMatchingUserEntityProfileEmail", false); + actions.setAppConfigProperty( + "auth.providers.microsoft.production.sessionDuration", + sessionDuration, + ); +} + +/** + * Autologout cases must pin a known-good resolver and clear leftover + * sessionDuration from earlier tests so cookie lifetime does not fight idle logout. + */ +export function configureOidcAutologout( + actions: AuthConfigActions, + options: { idleTimeoutMinutes: number; promptBeforeIdleSeconds: number }, +): void { + setOIDCResolver(actions, "emailMatchingUserEntityProfileEmail", false); + actions.deleteAppConfigProperty("auth.providers.oidc.production.sessionDuration"); + actions.setAppConfigProperty("auth.autologout.enabled", true); + actions.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", options.idleTimeoutMinutes); + actions.setAppConfigProperty( + "auth.autologout.promptBeforeIdleSeconds", + options.promptBeforeIdleSeconds, + ); +} + export function setGitlabResolver( actions: AuthConfigActions, resolver: string, @@ -349,21 +404,7 @@ export function setDynamicPluginEnabled( pluginName: string, enabled: boolean, ): void { - const plugin = state.dynamicPluginsConfig.plugins.find((p) => p.package === pluginName); - if (plugin === undefined) { - state.dynamicPluginsConfig.plugins = [ - ...state.dynamicPluginsConfig.plugins, - { - package: pluginName, - disabled: !enabled, - }, - ]; - console.log( - `Plugin ${pluginName} has been added to the dynamic plugins config and set to ${enabled ? "enabled" : "disabled"}.`, - ); - return; - } - plugin.disabled = !enabled; + setPluginEnabled(state.dynamicPluginsConfig, pluginName, enabled); console.log(`Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`); } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts index 1670abdb94..2061d4261d 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts @@ -1,13 +1,10 @@ import { GroupEntity } from "@backstage/catalog-model"; import { APIHelper } from "../../api-helper"; -import { - getCatalogGroups, - getCatalogUsers, - isGroupEntity, - isUserEntity, - RHDHDeploymentState, -} from "./types"; +import { getCatalogGroups, getCatalogUsers, isGroupEntity, RHDHDeploymentState } from "./types"; + +/** Poll budget for Keycloak/OIDC entities to appear after provider sync. */ +export const CATALOG_INGESTION_POLL_TIMEOUT_MS = 120_000; export function parseGroupMemberFromEntity(group: GroupEntity): string[] { if (group.relations === undefined) { @@ -32,6 +29,17 @@ export function parseGroupParentFromEntity(group: GroupEntity): string[] { return group.relations.filter((r) => r.type === "childOf").map((r) => r.targetRef.split("/")[1]); } +/** + * Pure predicate for ingestion checks — kept free of I/O so unit tests can + * lock the displayName matching rule without a live catalog. + */ +export function catalogDisplayNamesInclude( + displayNames: readonly string[], + expected: readonly string[], +): boolean { + return expected.every((elem) => displayNames.includes(elem)); +} + async function createCatalogApi( state: RHDHDeploymentState, computeBackstageBackendUrl: () => Promise, @@ -42,110 +50,165 @@ async function createCatalogApi( return api; } -export async function checkUserIsIngestedInCatalog( +/** + * Poll until `check` returns true. Hard errors inside `check` (HTTP non-2xx, + * malformed entities) propagate immediately — only "not present yet" should + * return false. + */ +async function waitUntilCatalogReady(check: () => Promise, label: string): Promise { + const { expect } = await import("@playwright/test"); + await expect + .poll(check, { + timeout: CATALOG_INGESTION_POLL_TIMEOUT_MS, + intervals: [2_000, 5_000, 10_000], + message: label, + }) + .toBe(true); +} + +function requireGroupEntity(entity: unknown, group: string): GroupEntity { + if (!isGroupEntity(entity)) { + throw new TypeError(`Invalid group entity for ${group}: ${JSON.stringify(entity)}`); + } + return entity; +} + +/** + * Wait until the listed users appear in the catalog (by displayName). + * Resolves on success; throws on HTTP/shape errors or poll timeout. + */ +export function checkUserIsIngestedInCatalog( state: RHDHDeploymentState, users: string[], computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const response: unknown = await api.getAllCatalogUsersFromAPI(); - const catalogUsers = getCatalogUsers(response); - const { expect } = await import("@playwright/test"); - expect(catalogUsers.length).toBeGreaterThan(0); - const catalogUsersDisplayNames: string[] = catalogUsers - .map((u) => u.spec.profile?.displayName) - .filter((name): name is string => name !== undefined); - console.log( - `Checking ${JSON.stringify(catalogUsersDisplayNames)} contains users ${JSON.stringify(users)}`, +): Promise { + return waitUntilCatalogReady( + async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const response: unknown = await api.getAllCatalogUsersFromAPI(); + const catalogUsers = getCatalogUsers(response); + if (catalogUsers.length === 0) { + return false; + } + const catalogUsersDisplayNames: string[] = catalogUsers + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); + console.log( + `Checking ${JSON.stringify(catalogUsersDisplayNames)} contains users ${JSON.stringify(users)}`, + ); + return catalogDisplayNamesInclude(catalogUsersDisplayNames, users); + }, + `catalog users include ${JSON.stringify(users)}`, ); - return users.every((elem) => catalogUsersDisplayNames.includes(elem)); } -export async function checkGroupIsIngestedInCatalog( +/** + * Wait until the listed groups appear in the catalog (by displayName). + * Resolves on success; throws on HTTP/shape errors or poll timeout. + */ +export function checkGroupIsIngestedInCatalog( state: RHDHDeploymentState, groups: string[], computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const response: unknown = await api.getAllCatalogGroupsFromAPI(); - const catalogGroups = getCatalogGroups(response); - const { expect } = await import("@playwright/test"); - expect(catalogGroups.length).toBeGreaterThan(0); - const catalogGroupsDisplayNames: string[] = catalogGroups - .map((u) => u.spec.profile?.displayName) - .filter((name): name is string => name !== undefined); - console.log( - `Checking ${JSON.stringify(catalogGroupsDisplayNames)} contains groups ${JSON.stringify(groups)}`, +): Promise { + return waitUntilCatalogReady( + async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const response: unknown = await api.getAllCatalogGroupsFromAPI(); + const catalogGroups = getCatalogGroups(response); + if (catalogGroups.length === 0) { + return false; + } + const catalogGroupsDisplayNames: string[] = catalogGroups + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); + console.log( + `Checking ${JSON.stringify(catalogGroupsDisplayNames)} contains groups ${JSON.stringify(groups)}`, + ); + return catalogDisplayNamesInclude(catalogGroupsDisplayNames, groups); + }, + `catalog groups include ${JSON.stringify(groups)}`, ); - return groups.every((elem) => catalogGroupsDisplayNames.includes(elem)); } -export async function checkUserIsInGroup( +export function checkUserIsInGroup( state: RHDHDeploymentState, user: string, group: string, computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const entity: unknown = await api.getGroupEntityFromAPI(group); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${group}`); - } - const members = parseGroupMemberFromEntity(entity); - console.log(`Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`); - return members.includes(user); +): Promise { + return waitUntilCatalogReady(async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity = await api.getGroupEntityFromAPI(group); + if (entity === null) { + return false; + } + const groupEntity = requireGroupEntity(entity, group); + const members = parseGroupMemberFromEntity(groupEntity); + console.log(`Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`); + return members.includes(user); + }, `group ${group} contains user ${user}`); } -export async function checkGroupIsParentOfGroup( +export function checkGroupIsParentOfGroup( state: RHDHDeploymentState, parent: string, child: string, computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const entity: unknown = await api.getGroupEntityFromAPI(parent); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${parent}`); - } - const children = parseGroupChildrenFromEntity(entity); - console.log( - `Checking children of ${parent} (${JSON.stringify(children)}) contain group ${child}`, - ); - return children.includes(child); +): Promise { + return waitUntilCatalogReady(async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity = await api.getGroupEntityFromAPI(parent); + if (entity === null) { + return false; + } + const groupEntity = requireGroupEntity(entity, parent); + const children = parseGroupChildrenFromEntity(groupEntity); + console.log( + `Checking children of ${parent} (${JSON.stringify(children)}) contain group ${child}`, + ); + return children.includes(child); + }, `group ${parent} is parent of ${child}`); } -export async function checkGroupIsChildOfGroup( +export function checkGroupIsChildOfGroup( state: RHDHDeploymentState, child: string, parent: string, computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const entity: unknown = await api.getGroupEntityFromAPI(child); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${child}`); - } - const parents = parseGroupParentFromEntity(entity); - console.log(`Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`); - return parents.includes(parent); +): Promise { + return waitUntilCatalogReady(async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity = await api.getGroupEntityFromAPI(child); + if (entity === null) { + return false; + } + const groupEntity = requireGroupEntity(entity, child); + const parents = parseGroupParentFromEntity(groupEntity); + console.log( + `Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`, + ); + return parents.includes(parent); + }, `group ${child} is child of ${parent}`); } -export async function checkUserHasAnnotation( +export function checkUserHasAnnotation( state: RHDHDeploymentState, user: string, annotationKey: string, expectedValue: string, computeBackstageBackendUrl: () => Promise, -): Promise { - const api = await createCatalogApi(state, computeBackstageBackendUrl); - const entity: unknown = await api.getCatalogUserFromAPI(user); - if (!isUserEntity(entity)) { - throw new Error(`Invalid user entity for ${user}`); - } - const annotations = entity.metadata?.annotations ?? {}; - const actualValue = annotations[annotationKey]; - console.log( - `Checking user ${user} has annotation ${annotationKey}=${expectedValue}, actual value: ${actualValue}`, - ); - return actualValue === expectedValue; +): Promise { + return waitUntilCatalogReady(async () => { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity = await api.getCatalogUserFromAPI(user); + if (entity === null) { + return false; + } + const actualValue = entity.metadata.annotations?.[annotationKey]; + console.log( + `Checking user ${user} has annotation ${annotationKey}=${expectedValue}, actual value: ${actualValue}`, + ); + return actualValue === expectedValue; + }, `user ${user} has annotation ${annotationKey}=${expectedValue}`); } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/config-liveness.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/config-liveness.ts new file mode 100644 index 0000000000..c4cea3fae0 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/config-liveness.ts @@ -0,0 +1,306 @@ +/** + * Prove auth config is persisted and the new workload has mounted it. + * + * Backstage reads app-config at process start. Updating the ConfigMap alone is + * not enough — reconcile must force new pods and prove the marker is present in + * the mounted file before tests assume resolvers / sessionDuration / autologout. + * + * Does not wait for Deployment Available — callers use HTTP /healthcheck next + * so 503s fail fast instead of burning the Available timeout. + */ + +import * as k8s from "@kubernetes/client-node"; +import * as yaml from "yaml"; + +import { isKubernetesNotFoundError } from "../../errors"; +import { execPodCommandImpl } from "../../kube-client/exec"; +import { wrapKubernetesError } from "../../kube-client/helpers"; +import { pollUntil } from "../../poll-until"; +import { buildDeploymentLabelSelector, labelSelectorFromMatchLabels } from "./deployment-labels"; +import { RHDHDeploymentState, isRecord } from "./types"; + +const POLL_INTERVAL_MS = 500; +const POD_UID_WAIT_TIMEOUT_MS = 120_000; +const MOUNTED_APP_CONFIG_PATH = "/opt/app-root/src/app-config.yaml"; +const BACKSTAGE_BACKEND_CONTAINER = "backstage-backend"; + +async function getLabeledDeployment(state: RHDHDeploymentState): Promise { + const labelSelector = buildDeploymentLabelSelector(state.instanceName); + try { + const deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (deployments.body.items.length === 0) { + throw new Error(`No deployment found with labels: ${labelSelector}`); + } + + return deployments.body.items[0]; + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith("No deployment found")) { + throw error; + } + throw wrapKubernetesError( + `Failed to list deployments in ${state.namespace} (${labelSelector})`, + error, + ); + } +} + +async function getPodLabelSelector(state: RHDHDeploymentState): Promise { + const deployment = await getLabeledDeployment(state); + const matchLabels = deployment.spec?.selector?.matchLabels ?? {}; + if (Object.keys(matchLabels).length === 0) { + throw new Error( + `Deployment ${deployment.metadata?.name ?? "unknown"} has no selector.matchLabels`, + ); + } + return labelSelectorFromMatchLabels(matchLabels); +} + +async function listRunningPods( + state: RHDHDeploymentState, + labelSelector: string, +): Promise { + try { + const pods = await state.k8sApi.listNamespacedPod( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + return pods.body.items.filter( + (pod) => pod.status?.phase === "Running" && pod.metadata?.deletionTimestamp === undefined, + ); + } catch (error: unknown) { + throw wrapKubernetesError( + `Failed to list pods in ${state.namespace} (${labelSelector})`, + error, + ); + } +} + +async function listRunningPodUids(state: RHDHDeploymentState): Promise { + const labelSelector = await getPodLabelSelector(state); + const pods = await listRunningPods(state, labelSelector); + return pods + .map((pod) => pod.metadata?.uid) + .filter((uid): uid is string => typeof uid === "string" && uid.length > 0) + .toSorted(); +} + +async function newestRunningPodName(state: RHDHDeploymentState): Promise { + const labelSelector = await getPodLabelSelector(state); + const pods = await listRunningPods(state, labelSelector); + if (pods.length === 0) { + throw new Error(`No Running pods with labels ${labelSelector} in ${state.namespace}`); + } + pods.sort((a, b) => { + const aCreated = a.metadata?.creationTimestamp + ? new Date(a.metadata.creationTimestamp).getTime() + : 0; + const bCreated = b.metadata?.creationTimestamp + ? new Date(b.metadata.creationTimestamp).getTime() + : 0; + return bCreated - aCreated; + }); + const name = pods[0]?.metadata?.name; + if (name === undefined || name === "") { + throw new Error(`Running pod missing metadata.name (${labelSelector})`); + } + return name; +} + +/** True when remote YAML parses to the same object as in-memory appConfig. */ +export function appConfigMatchesExpected( + remoteYaml: string, + expectedAppConfig: Record, +): boolean { + const remoteParsed: unknown = yaml.parse(remoteYaml); + if (!isRecord(remoteParsed)) { + return false; + } + const expectedNormalized: unknown = yaml.parse(yaml.stringify(expectedAppConfig)); + return JSON.stringify(remoteParsed) === JSON.stringify(expectedNormalized); +} + +/** + * Re-read the remote ConfigMap and fail if it does not match in-memory appConfig. + * Local mode writes the file during updateAppConfig; nothing to assert remotely. + */ +export async function assertAppConfigPersisted(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + return; + } + + let response: { body: k8s.V1ConfigMap }; + try { + response = await state.k8sApi.readNamespacedConfigMap(state.appConfigMap, state.namespace); + } catch (error: unknown) { + throw wrapKubernetesError( + `Failed to read ConfigMap ${state.appConfigMap} in ${state.namespace}`, + error, + ); + } + const remoteYaml = response.body.data?.["app-config.yaml"]; + if (remoteYaml === undefined || remoteYaml === "") { + throw new Error( + `ConfigMap ${state.appConfigMap} in ${state.namespace} has no app-config.yaml data`, + ); + } + + if (!appConfigMatchesExpected(remoteYaml, state.appConfig)) { + throw new Error( + `Persisted app-config in ConfigMap ${state.appConfigMap} does not match expected in-memory config`, + ); + } + + console.log(`[INFO] Persisted app-config matches expected config (${state.appConfigMap})`); +} + +/** + * Prove the new pod's mounted app-config contains the reconcile marker + * (effective load path, not just ConfigMap API bytes). + */ +export async function assertMountedAppConfigContainsMarker( + state: RHDHDeploymentState, + marker: string, +): Promise { + if (state.isRunningLocal) { + return; + } + + await pollUntil( + async () => { + try { + const podName = await newestRunningPodName(state); + const { stdout } = await execPodCommandImpl( + state.kc, + podName, + state.namespace, + BACKSTAGE_BACKEND_CONTAINER, + ["cat", MOUNTED_APP_CONFIG_PATH], + 30_000, + ); + return stdout.includes(marker); + } catch (error) { + console.log( + `[INFO] Mounted app-config marker check not ready yet: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + }, + { + timeoutMs: POD_UID_WAIT_TIMEOUT_MS, + intervalMs: POLL_INTERVAL_MS, + label: `Mounted app-config contains marker ${marker}`, + }, + ); + console.log(`[INFO] Mounted app-config contains reconcile marker ${marker}`); +} + +/** + * Force new pods so they remount ConfigMaps and Backstage reloads config. + * Deletes Running pods (operator recreates them) rather than patching the + * Deployment template, which the operator may overwrite on reconcile. + */ +export async function restartRemoteDeployment(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + return; + } + + const labelSelector = await getPodLabelSelector(state); + const running = await listRunningPods(state, labelSelector); + + if (running.length === 0) { + throw new Error( + `No Running pods found to restart with labels ${labelSelector} in ${state.namespace}`, + ); + } + + for (const pod of running) { + const name = pod.metadata?.name; + if (name === undefined || name === "") { + continue; + } + console.log(`[INFO] Deleting pod ${name} to reload auth config`); + try { + await state.k8sApi.deleteNamespacedPod(name, state.namespace); + } catch (error: unknown) { + // Pod may already be terminating from a concurrent reconcile. + if (isKubernetesNotFoundError(error)) { + console.log(`[INFO] Pod ${name} already gone — continuing restart`); + continue; + } + throw wrapKubernetesError(`Failed to delete pod ${name} in ${state.namespace}`, error); + } + } +} + +/** + * After a forced restart, wait until Running pod UIDs differ from the baseline + * (proves we are not still talking to the pre-restart process). + */ +export async function waitForPodUidChange( + state: RHDHDeploymentState, + previousUids: readonly string[], + timeoutMs: number = POD_UID_WAIT_TIMEOUT_MS, +): Promise { + if (state.isRunningLocal) { + return; + } + + const baseline = [...previousUids].toSorted().join(","); + await pollUntil( + async () => { + const current = await listRunningPodUids(state); + if (current.length === 0) { + return false; + } + return current.join(",") !== baseline; + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Pod UID change after config restart (was [${baseline}])`, + }, + ); + console.log("[INFO] New Running pod UID(s) detected after config restart"); +} + +export function captureRunningPodUids(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + return Promise.resolve([]); + } + return listRunningPodUids(state); +} + +/** + * Remote config-liveness: persisted CM → restart → new pods → marker in mount. + * Callers must follow with HTTP /healthcheck (not Deployment Available). + */ +export async function waitUntilAuthConfigLive( + state: RHDHDeploymentState, + configMarker: string, +): Promise { + if (state.isRunningLocal) { + return; + } + + await assertAppConfigPersisted(state); + const previousUids = await captureRunningPodUids(state); + await restartRemoteDeployment(state); + await waitForPodUidChange(state, previousUids); + await assertMountedAppConfigContainsMarker(state, configMarker); + console.log("[INFO] Auth config live: persisted, restarted, marker mounted"); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/deployment-labels.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/deployment-labels.ts new file mode 100644 index 0000000000..4f9f14826a --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/deployment-labels.ts @@ -0,0 +1,19 @@ +/** Label selectors for the operator-managed Backstage Deployment / pods. */ + +const BACKSTAGE_NAME_LABEL = { + "app.kubernetes.io/name": "backstage", +} as const; + +export function buildDeploymentLabelSelector(instanceName: string): string { + const labels = { + ...BACKSTAGE_NAME_LABEL, + "app.kubernetes.io/instance": instanceName, + }; + return labelSelectorFromMatchLabels(labels); +} + +export function labelSelectorFromMatchLabels(matchLabels: Record): string { + return Object.entries(matchLabels) + .map(([key, value]) => `${key}=${value}`) + .join(","); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts index 432e3be1a2..36953dc3ca 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts @@ -5,6 +5,10 @@ import { expect } from "@playwright/test"; import { v4 as uuidv4 } from "uuid"; import { + configureGithubSessionDuration as configureGithubSessionDurationImpl, + configureMicrosoftSessionDuration as configureMicrosoftSessionDurationImpl, + configureOidcAutologout as configureOidcAutologoutImpl, + configureOidcSessionDuration as configureOidcSessionDurationImpl, enableGithubLoginWithIngestion, enableGitlabLoginWithIngestion, enableLDAPLoginWithIngestion, @@ -29,6 +33,7 @@ import { parseGroupMemberFromEntity, parseGroupParentFromEntity, } from "./catalog"; +import { waitUntilAuthConfigLive as waitUntilAuthConfigLiveImpl } from "./config-liveness"; import { applyCustomResource, computeBackstageBackendUrl as computeBackstageBackendUrlImpl, @@ -70,7 +75,9 @@ import { import { deleteNamespaceIfExists as deleteNamespaceIfExistsImpl, getDeploymentGeneration as getDeploymentGenerationImpl, + tryGetDeploymentGeneration as tryGetDeploymentGenerationImpl, waitForConfigReconciled as waitForConfigReconciledImpl, + waitForDeploymentCreated as waitForDeploymentCreatedImpl, waitForDeploymentReady as waitForDeploymentReadyImpl, waitForNamespaceActive as waitForNamespaceActiveImpl, } from "./wait"; @@ -193,6 +200,30 @@ class RHDHDeployment implements RHDHDeploymentState { return this.setConfigProperty(this.appConfig, path, value); } + deleteAppConfigProperty(path: string): RHDHDeployment { + const parts = path.split("."); + let current: Record = this.appConfig; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (part === undefined) { + throw new Error(`Invalid config path: ${path}`); + } + const next = current[part]; + if (!isRecord(next)) { + return this; + } + current = next; + } + + const lastPart = parts.at(-1); + if (lastPart === undefined) { + throw new Error(`Invalid config path: ${path}`); + } + delete current[lastPart]; + return this; + } + getAppConfig(): YamlConfig { return this.getConfig(this.appConfig); } @@ -258,11 +289,21 @@ class RHDHDeployment implements RHDHDeploymentState { return this; } + async waitUntilAuthConfigLive(configMarker: string): Promise { + await waitUntilAuthConfigLiveImpl(this, configMarker); + return this; + } + async waitForDeploymentReady(timeoutMs = 600000): Promise { await waitForDeploymentReadyImpl(this, timeoutMs); return this; } + async waitForDeploymentCreated(timeoutMs = 600000): Promise { + await waitForDeploymentCreatedImpl(this, timeoutMs); + return this; + } + async waitForNamespaceActive(timeoutMs = 30000): Promise { await waitForNamespaceActiveImpl(this, timeoutMs); return this; @@ -318,8 +359,8 @@ class RHDHDeployment implements RHDHDeploymentState { return loadBackstageCRImpl(this); } - async createBackstageDeployment(): Promise { - await createBackstageDeploymentImpl(this); + async createBackstageDeployment(options?: { waitForReady?: boolean }): Promise { + await createBackstageDeploymentImpl(this, options); return this; } @@ -434,7 +475,10 @@ class RHDHDeployment implements RHDHDeploymentState { async updateAllConfigs(): Promise { if (!this.isRunningLocal) { - this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); + // First-time prepareProvider updates configs before createBackstageDeployment, + // so the deployment may not exist yet. Baseline is only needed for later + // reconcileAfterConfigChange waits. + this.configReconcileBaselineGeneration = await tryGetDeploymentGenerationImpl(this); } await this.updateAppConfig(); await this.updateDynamicPluginsConfig(); @@ -485,6 +529,31 @@ class RHDHDeployment implements RHDHDeploymentState { return Promise.resolve(this); } + /** Pin username resolver + sessionDuration so earlier resolver tests cannot leak. */ + configureGithubSessionDuration(sessionDuration: string): RHDHDeployment { + configureGithubSessionDurationImpl(this, sessionDuration); + return this; + } + + configureOidcSessionDuration(sessionDuration: string): RHDHDeployment { + configureOidcSessionDurationImpl(this, sessionDuration); + return this; + } + + configureMicrosoftSessionDuration(sessionDuration: string): RHDHDeployment { + configureMicrosoftSessionDurationImpl(this, sessionDuration); + return this; + } + + /** Pin email resolver + autologout; clear leftover sessionDuration from prior cases. */ + configureOidcAutologout(options: { + idleTimeoutMinutes: number; + promptBeforeIdleSeconds: number; + }): RHDHDeployment { + configureOidcAutologoutImpl(this, options); + return this; + } + enableGitlabLoginWithIngestion(): Promise { enableGitlabLoginWithIngestion(this); return Promise.resolve(this); @@ -507,23 +576,25 @@ class RHDHDeployment implements RHDHDeploymentState { parseGroupChildrenFromEntity = parseGroupChildrenFromEntity; parseGroupParentFromEntity = parseGroupParentFromEntity; - checkUserIsIngestedInCatalog(users: string[]): Promise { + /** Polls until users are ingested; throws on HTTP/shape errors or timeout. */ + checkUserIsIngestedInCatalog(users: string[]): Promise { return checkUserIsIngestedInCatalog(this, users, () => this.computeBackstageBackendUrl()); } - checkGroupIsIngestedInCatalog(groups: string[]): Promise { + /** Polls until groups are ingested; throws on HTTP/shape errors or timeout. */ + checkGroupIsIngestedInCatalog(groups: string[]): Promise { return checkGroupIsIngestedInCatalog(this, groups, () => this.computeBackstageBackendUrl()); } - checkUserIsInGroup(user: string, group: string): Promise { + checkUserIsInGroup(user: string, group: string): Promise { return checkUserIsInGroup(this, user, group, () => this.computeBackstageBackendUrl()); } - checkGroupIsParentOfGroup(parent: string, child: string): Promise { + checkGroupIsParentOfGroup(parent: string, child: string): Promise { return checkGroupIsParentOfGroup(this, parent, child, () => this.computeBackstageBackendUrl()); } - checkGroupIsChildOfGroup(child: string, parent: string): Promise { + checkGroupIsChildOfGroup(child: string, parent: string): Promise { return checkGroupIsChildOfGroup(this, child, parent, () => this.computeBackstageBackendUrl()); } @@ -531,7 +602,7 @@ class RHDHDeployment implements RHDHDeploymentState { user: string, annotationKey: string, expectedValue: string, - ): Promise { + ): Promise { return checkUserHasAnnotation(this, user, annotationKey, expectedValue, () => this.computeBackstageBackendUrl(), ); diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts index 07e8fc059d..acb753525e 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts @@ -6,7 +6,14 @@ import * as k8s from "@kubernetes/client-node"; import { expect } from "@playwright/test"; import * as yaml from "yaml"; -import { hasErrorResponse } from "../../errors"; +import { applyDynamicPluginsProfile } from "../../dynamic-plugins-profile"; +import { isKubernetesConflictError } from "../../errors"; +import { predictedUrl } from "../../instance-route-identity"; +import { getKubeApiErrorMessage } from "../../kube-client/helpers"; +import { + applyOperatorInstallProfileToAppConfig, + applyOperatorInstallProfileToCr, +} from "../../operator-install-profile"; import { pollUntil } from "../../poll-until"; import { BackstageCr, @@ -16,6 +23,7 @@ import { isRecord, RHDHDeploymentState, rootDirName, + yamlsDirName, } from "./types"; import { ensureBackstageCRIsAvailable, waitForDeploymentReady } from "./wait"; @@ -58,7 +66,7 @@ export async function createNamespace(state: RHDHDeploymentState): Promise try { await state.k8sApi.createNamespace(namespaceObj); } catch (e) { - if (hasErrorResponse(e) && e.response?.statusCode === 409) { + if (isKubernetesConflictError(e)) { return; } throw e; @@ -79,7 +87,18 @@ async function createConfigMap( }, data, }; - await state.k8sApi.createNamespacedConfigMap(state.namespace, configMap); + try { + await state.k8sApi.createNamespacedConfigMap(state.namespace, configMap); + } catch (error: unknown) { + if (isKubernetesConflictError(error)) { + console.log(`[INFO] ConfigMap ${name} already exists — replacing data`); + await updateConfigMap(state, name, data); + return; + } + throw new Error(`Failed to create ConfigMap ${name}: ${getKubeApiErrorMessage(error)}`, { + cause: error, + }); + } } async function updateConfigMap( @@ -93,26 +112,35 @@ async function updateConfigMap( } const patch = [{ op: "replace", path: "/data", value: data }]; - await state.k8sApi.patchNamespacedConfigMap( - name, - state.namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - { headers: { "Content-Type": "application/json-patch+json" } }, - ); + try { + await state.k8sApi.patchNamespacedConfigMap( + name, + state.namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { "Content-Type": "application/json-patch+json" } }, + ); + } catch (error: unknown) { + throw new Error(`Failed to update ConfigMap ${name}: ${getKubeApiErrorMessage(error)}`, { + cause: error, + }); + } } export async function loadBaseConfig(state: RHDHDeploymentState): Promise { - const configPath = join(currentDirName, "yamls", "configmap.yaml"); + const configPath = join(yamlsDirName, "configmap.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); const configData: unknown = yaml.parse(yamlContent); if (isRecord(configData)) { state.appConfig = configData; + if (!state.isRunningLocal) { + applyOperatorInstallProfileToAppConfig(state.appConfig, "auth-providers"); + } } } @@ -175,23 +203,55 @@ export async function createSecret(state: RHDHDeploymentState): Promise { }, data: state.secretData, }; - await state.k8sApi.createNamespacedSecret(state.namespace, secret); + try { + await state.k8sApi.createNamespacedSecret(state.namespace, secret); + } catch (error: unknown) { + // Worker-restart reuse keeps the namespace; create must upsert so retries + // do not die on AlreadyExists before enableProvider runs. + if (isKubernetesConflictError(error)) { + console.log( + `[INFO] Secret ${state.secretName} already exists — replacing with current secret data`, + ); + await updateSecret(state); + return; + } + throw new Error( + `Failed to create secret ${state.secretName}: ${getKubeApiErrorMessage(error)}`, + { cause: error }, + ); + } } export async function updateSecret(state: RHDHDeploymentState): Promise { if (skipIfRunningLocal(state, "Skipping secret update as isRunningLocal is true.")) { return; } - const secret: k8s.V1Secret = { - apiVersion: "v1", - kind: "Secret", - metadata: { - name: state.secretName, - namespace: state.namespace, - }, - data: state.secretData, - }; - await state.k8sApi.replaceNamespacedSecret(state.secretName, state.namespace, secret); + // Read-merge-replace keeps resourceVersion, labels, annotations, and type so + // replace is conditional and does not clobber operator-owned metadata. + // Retry once on conflict — concurrent writers can invalidate resourceVersion. + let lastError: unknown; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const existing = await state.k8sApi.readNamespacedSecret(state.secretName, state.namespace); + const body = existing.body; + body.data = state.secretData; + await state.k8sApi.replaceNamespacedSecret(state.secretName, state.namespace, body); + return; + } catch (error: unknown) { + lastError = error; + if (attempt < 2 && isKubernetesConflictError(error)) { + console.log( + `[INFO] Secret ${state.secretName} replace conflict — retrying read-merge-replace`, + ); + continue; + } + break; + } + } + throw new Error( + `Failed to update secret ${state.secretName}: ${getKubeApiErrorMessage(lastError)}`, + { cause: lastError }, + ); } export async function deleteSecret(state: RHDHDeploymentState): Promise { @@ -202,7 +262,7 @@ export async function deleteSecret(state: RHDHDeploymentState): Promise { } export async function loadRbacConfig(state: RHDHDeploymentState): Promise { - const configPath = join(currentDirName, "yamls", "rbac-policy.csv"); + const configPath = join(yamlsDirName, "rbac-policy.csv"); state.rbacConfig = await fs.readFile(configPath, "utf8"); } @@ -237,12 +297,13 @@ export async function updateRbacConfig(state: RHDHDeploymentState): Promise { - const configPath = join(currentDirName, "yamls", "dynamic-plugins-config.yaml"); + const configPath = join(yamlsDirName, "dynamic-plugins-config.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); const configData: unknown = yaml.parse(yamlContent); if (isDynamicPluginsConfig(configData)) { state.dynamicPluginsConfig = configData; + applyDynamicPluginsProfile(state.dynamicPluginsConfig); } } @@ -289,7 +350,7 @@ export async function updateDynamicPluginsConfig(state: RHDHDeploymentState): Pr } export async function loadBackstageCR(state: RHDHDeploymentState): Promise { - const configPath = join(currentDirName, "yamls", "backstage.yaml"); + const configPath = join(yamlsDirName, "backstage.yaml"); const parsed: unknown = await readYamlToJson(configPath); if (!isBackstageCr(parsed)) { throw new Error("Invalid Backstage CR config"); @@ -318,6 +379,7 @@ export async function loadBackstageCR(state: RHDHDeploymentState): Promise { +export async function createBackstageDeployment( + state: RHDHDeploymentState, + options: { waitForReady?: boolean } = {}, +): Promise { + const waitForReady = options.waitForReady ?? true; try { if (state.isRunningLocal) { startLocalBackstageProcess(state); @@ -376,7 +442,9 @@ export async function createBackstageDeployment(state: RHDHDeploymentState): Pro await ensureBackstageCRIsAvailable(state, 60000); const backstageConfig = await loadBackstageCR(state); await applyCustomResource(state, backstageConfig); - await waitForDeploymentReady(state); + if (waitForReady) { + await waitForDeploymentReady(state); + } } catch (e) { console.log(JSON.stringify(e)); throw e; @@ -436,7 +504,13 @@ export function computeBackstageUrl(state: RHDHDeploymentState): string { if (clusterBaseUrl === "") { console.log("No match found."); } - return `https://backstage-${state.instanceName}-${state.namespace}.apps.${clusterBaseUrl}`; + // Auth providers always deploy via the operator. + return predictedUrl({ + installMethod: "operator", + releaseName: state.instanceName, + namespace: state.namespace, + routerBase: `apps.${clusterBaseUrl}`, + }); } export function computeBackstageBackendUrl(state: RHDHDeploymentState): string { diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts index 126e54ade6..79312f5669 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts @@ -12,10 +12,16 @@ function attachLogMatchHandlers( onMatch: () => void, onDebug?: (text: string) => void, ): void { + // Accumulate across chunks — a "Committed N users" line can split mid-stream. + let buffer = ""; logStream.on("data", (chunk: Buffer | string) => { const text = typeof chunk === "string" ? chunk : chunk.toString(); onDebug?.(text); - if (searchString.test(text)) { + buffer += text; + if (buffer.length > 512_000) { + buffer = buffer.slice(-256_000); + } + if (searchString.test(buffer)) { process.stdout.write(chunk); onMatch(); } @@ -62,6 +68,18 @@ async function resolvePodName( throw new Error(`No active pods found with labels: ${labelSelector}`); } + // Prefer the newest Running pod — after reconcile the old replica can still + // appear briefly and watching it misses the catalog sync on the new pod. + activePods.sort((a, b) => { + const aCreated = a.metadata?.creationTimestamp + ? new Date(a.metadata.creationTimestamp).getTime() + : 0; + const bCreated = b.metadata?.creationTimestamp + ? new Date(b.metadata.creationTimestamp).getTime() + : 0; + return bCreated - aCreated; + }); + const pod = activePods[0]; const resolvedName = pod.metadata?.name; if (resolvedName === undefined || resolvedName === "") { @@ -99,9 +117,11 @@ async function streamPodLogsUntilMatch( }); try { + // Catalog sync often completes before we attach. A tiny tail misses the + // "Committed N users" line and times out even though ingestion succeeded. await log.log(state.namespace, podName, "backstage-backend", logStream, { follow: true, - tailLines: 1, + tailLines: 2000, pretty: false, timestamps: false, }); diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts index 2f24be308b..6e64c4d403 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts @@ -8,6 +8,7 @@ export type YamlConfig = Record; export interface DynamicPluginConfig { package: string; + enabled?: boolean; disabled?: boolean; } @@ -18,6 +19,9 @@ export type DynamicPluginsConfig = Record & { export interface BackstageCrSpec { replicas?: number; deployment?: unknown; + application?: unknown; + flavours?: unknown[]; + [key: string]: unknown; } export interface BackstageCr { @@ -85,19 +89,33 @@ export function isGroupEntity(value: unknown): value is GroupEntity { export function getCatalogUsers(response: unknown): UserEntity[] { if (!isRecord(response) || !Array.isArray(response.items)) { - return []; + throw new TypeError(`Invalid catalog users response: ${JSON.stringify(response)}`); } - return response.items.filter(isUserEntity); + const users = response.items.filter(isUserEntity); + if (users.length !== response.items.length) { + throw new TypeError( + `Catalog users response contained non-User items: ${JSON.stringify(response.items)}`, + ); + } + return users; } export function getCatalogGroups(response: unknown): GroupEntity[] { if (!isRecord(response) || !Array.isArray(response.items)) { - return []; + throw new TypeError(`Invalid catalog groups response: ${JSON.stringify(response)}`); + } + const groups = response.items.filter(isGroupEntity); + if (groups.length !== response.items.length) { + throw new TypeError( + `Catalog groups response contained non-Group items: ${JSON.stringify(response.items)}`, + ); } - return response.items.filter(isGroupEntity); + return groups; } export const currentDirName = import.meta.dirname; +/** Fixture YAMLs live beside `rhdh-deployment/`, not inside it. */ +export const yamlsDirName = resolvePath(currentDirName, "..", "yamls"); export const rootDirName = resolvePath(currentDirName, "..", "..", "..", ".."); export const syncedLogRegex = diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts index b1b460069d..b74a14e5df 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts @@ -1,13 +1,11 @@ import * as k8s from "@kubernetes/client-node"; import { getErrorMessage, hasErrorResponse } from "../../errors"; +import { wrapKubernetesError } from "../../kube-client/helpers"; import { pollUntil, pollUntilStable } from "../../poll-until"; +import { buildDeploymentLabelSelector } from "./deployment-labels"; import { BackstageCr, RHDHDeploymentState } from "./types"; -const BACKSTAGE_LABELS = { - "app.kubernetes.io/name": "backstage", -} as const; - const POLL_INTERVAL_MS = 500; function skipIfRunningLocal(state: RHDHDeploymentState, message?: string): boolean { @@ -20,28 +18,43 @@ function skipIfRunningLocal(state: RHDHDeploymentState, message?: string): boole return false; } -function buildLabelSelector(instanceName: string): string { - const labels = { - ...BACKSTAGE_LABELS, - "app.kubernetes.io/instance": instanceName, - }; - return Object.entries(labels) - .map(([key, value]) => `${key}=${value}`) - .join(","); -} +type DeploymentListClient = { + listNamespacedDeployment: ( + namespace: string, + pretty?: string, + allowWatchBookmarks?: boolean, + continueToken?: string, + fieldSelector?: string, + labelSelector?: string, + ) => Promise<{ body: { items: k8s.V1Deployment[] } }>; +}; + +type DeploymentGenerationState = { + instanceName: string; + namespace: string; + appsV1Api: DeploymentListClient; +}; async function getLabeledDeployment( - state: RHDHDeploymentState, + state: DeploymentGenerationState, labelSelector: string, ): Promise { - const deployments = await state.appsV1Api.listNamespacedDeployment( - state.namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); + let deployments: { body: { items: k8s.V1Deployment[] } }; + try { + deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + } catch (error: unknown) { + throw wrapKubernetesError( + `Failed to list deployments in ${state.namespace} (${labelSelector})`, + error, + ); + } if (deployments.body.items.length === 0) { throw new Error(`No deployment found with labels: ${labelSelector}`); @@ -50,12 +63,47 @@ async function getLabeledDeployment( return deployments.body.items[0]; } -export async function getDeploymentGeneration(state: RHDHDeploymentState): Promise { - const labelSelector = buildLabelSelector(state.instanceName); +function isDeploymentNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("No deployment found with labels:"); +} + +async function tryGetLabeledDeployment( + state: DeploymentGenerationState, + labelSelector: string, +): Promise { + try { + return await getLabeledDeployment(state, labelSelector); + } catch (error) { + if (isDeploymentNotFoundError(error)) { + return undefined; + } + throw error; + } +} + +export async function getDeploymentGeneration(state: DeploymentGenerationState): Promise { + const labelSelector = buildDeploymentLabelSelector(state.instanceName); const deployment = await getLabeledDeployment(state, labelSelector); return deployment.metadata?.generation ?? 0; } +/** + * Like getDeploymentGeneration, but returns undefined when the operator has + * not created the deployment yet (first-time auth-provider deploy). + */ +export async function tryGetDeploymentGeneration( + state: DeploymentGenerationState, +): Promise { + try { + return await getDeploymentGeneration(state); + } catch (error) { + if (isDeploymentNotFoundError(error)) { + return undefined; + } + throw error; + } +} + export async function waitForConfigReconciled( state: RHDHDeploymentState, timeoutMs: number = 60000, @@ -67,16 +115,12 @@ export async function waitForConfigReconciled( const baseline = state.configReconcileBaselineGeneration ?? (await getDeploymentGeneration(state)); - try { - await pollUntil(async () => (await getDeploymentGeneration(state)) > baseline, { - timeoutMs, - intervalMs: POLL_INTERVAL_MS, - label: `Config reconcile (generation > ${baseline})`, - }); - console.log(`[INFO] Config reconciled - deployment generation > ${baseline}`); - } catch { - console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); - } + await pollUntil(async () => (await getDeploymentGeneration(state)) > baseline, { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Config reconcile (generation > ${baseline})`, + }); + console.log(`[INFO] Config reconciled - deployment generation > ${baseline}`); } function hasRolloutStarted( @@ -143,7 +187,12 @@ async function waitForRolloutStart( try { await pollUntil( async () => { - const deployment = await getLabeledDeployment(state, labelSelector); + // Operator creates the Deployment asynchronously after the CR is + // applied — treat "not found yet" as still waiting, not a hard fail. + const deployment = await tryGetLabeledDeployment(state, labelSelector); + if (deployment === undefined) { + return false; + } if (initialGeneration === 0) { initialGeneration = deployment.metadata?.generation ?? 0; @@ -219,10 +268,38 @@ export async function waitForDeploymentReady( return; } - const labelSelector = buildLabelSelector(state.instanceName); + const labelSelector = buildDeploymentLabelSelector(state.instanceName); await pollDeploymentReady(state, labelSelector, timeoutMs); } +/** + * Wait until the operator has created the Backstage Deployment object. + * Does not wait for Available — use HTTP /healthcheck for that. + */ +export async function waitForDeploymentCreated( + state: DeploymentGenerationState & { isRunningLocal: boolean }, + timeoutMs: number = 600000, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping deployment created check as isRunningLocal is true."); + return; + } + + const labelSelector = buildDeploymentLabelSelector(state.instanceName); + await pollUntil( + async () => { + const deployment = await tryGetLabeledDeployment(state, labelSelector); + return deployment !== undefined; + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Deployment created (${labelSelector})`, + }, + ); + console.log(`[INFO] Deployment created with labels: ${labelSelector}`); +} + export async function waitForNamespaceActive( state: RHDHDeploymentState, timeoutMs: number = 30000, diff --git a/e2e-tests/playwright/utils/authentication-providers/yamls/backstage.yaml b/e2e-tests/playwright/utils/authentication-providers/yamls/backstage.yaml index 887dbe5be2..1ace12845c 100644 --- a/e2e-tests/playwright/utils/authentication-providers/yamls/backstage.yaml +++ b/e2e-tests/playwright/utils/authentication-providers/yamls/backstage.yaml @@ -1,5 +1,5 @@ kind: Backstage -apiVersion: rhdh.redhat.com/v1alpha4 +apiVersion: rhdh.redhat.com/v1alpha5 metadata: name: rhdh spec: @@ -15,9 +15,13 @@ spec: value: "--no-node-snapshot" - name: NODE_TLS_REJECT_UNAUTHORIZED value: "0" + - name: BACKEND_SECRET + value: "super-secret-for-tests" secrets: - name: rhdh-secrets extraFiles: configMaps: - name: rbac-policy mountPath: /opt/app-root/src/rbac + # Disable default flavours (e.g. lightspeed) — OperatorInstallProfile also enforces this. + flavours: [] diff --git a/e2e-tests/playwright/utils/authentication-providers/yamls/configmap.yaml b/e2e-tests/playwright/utils/authentication-providers/yamls/configmap.yaml index ba4938b080..c254a0881d 100644 --- a/e2e-tests/playwright/utils/authentication-providers/yamls/configmap.yaml +++ b/e2e-tests/playwright/utils/authentication-providers/yamls/configmap.yaml @@ -18,7 +18,7 @@ backend: credentials: true auth: keys: - - secret: temp + - secret: ${BACKEND_SECRET} externalAccess: - type: static options: diff --git a/e2e-tests/playwright/utils/authentication-providers/yamls/dynamic-plugins-config.yaml b/e2e-tests/playwright/utils/authentication-providers/yamls/dynamic-plugins-config.yaml index b10d37c54f..1ae55b2f8e 100644 --- a/e2e-tests/playwright/utils/authentication-providers/yamls/dynamic-plugins-config.yaml +++ b/e2e-tests/playwright/utils/authentication-providers/yamls/dynamic-plugins-config.yaml @@ -1,3 +1,4 @@ +includes: [] plugins: - package: ./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic enabled: false @@ -9,3 +10,116 @@ plugins: enabled: false - package: ./dynamic-plugins/dist/backstage-community-plugin-rbac enabled: false + # Global header: top bar + profile dropdown (Settings / Sign-out). + # Auth CI uses includes: [] so defaults (including global-header) are not + # loaded. Mirror the local-harness full pluginConfig so Settings POM locators + # (KeyboardArrowDownOutlinedIcon / profile.settings) work after login. + - package: ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-global-header + enabled: true + pluginConfig: + dynamicPlugins: + frontend: + red-hat-developer-hub.backstage-plugin-global-header: + translationResources: + - importName: globalHeaderTranslations + ref: globalHeaderTranslationRef + mountPoints: + - mountPoint: application/header + importName: GlobalHeader + config: + position: above-sidebar + - mountPoint: global.header/component + importName: CompanyLogo + config: + priority: 200 + props: + to: "/" + - mountPoint: global.header/component + importName: SearchComponent + config: + priority: 100 + - mountPoint: global.header/component + importName: Spacer + config: + priority: 99 + props: + growFactor: 0 + - mountPoint: global.header/component + importName: HeaderIconButton + config: + priority: 90 + props: + title: Self-service + titleKey: create.title + icon: add + to: create + - mountPoint: global.header/component + importName: StarredDropdown + config: + priority: 85 + - mountPoint: global.header/component + importName: ApplicationLauncherDropdown + config: + priority: 82 + - mountPoint: global.header/application-launcher + importName: MenuItemLink + config: + section: Documentation + priority: 150 + props: + title: Developer Hub + titleKey: applicationLauncher.developerHub + icon: developerHub + link: https://docs.redhat.com/en/documentation/red_hat_developer_hub + - mountPoint: global.header/application-launcher + importName: MenuItemLink + config: + section: Developer Tools + priority: 100 + props: + title: RHDH Local + titleKey: applicationLauncher.rhdhLocal + icon: developerHub + link: https://github.com/redhat-developer/rhdh-local + - mountPoint: global.header/component + importName: HelpDropdown + config: + priority: 80 + - mountPoint: global.header/help + importName: SupportButton + config: + priority: 10 + - mountPoint: global.header/component + importName: NotificationButton + config: + priority: 70 + - mountPoint: global.header/component + importName: Divider + config: + priority: 50 + - mountPoint: global.header/component + importName: ProfileDropdown + config: + priority: 10 + - mountPoint: global.header/profile + importName: MenuItemLink + config: + priority: 100 + props: + title: Settings + titleKey: profile.settings + link: /settings + icon: manageAccounts + - mountPoint: global.header/profile + importName: MenuItemLink + config: + priority: 90 + props: + title: My profile + titleKey: profile.myProfile + type: myProfile + icon: account + - mountPoint: global.header/profile + importName: LogoutButton + config: + priority: 10 diff --git a/e2e-tests/playwright/utils/cloudsql-config.ts b/e2e-tests/playwright/utils/cloudsql-config.ts new file mode 100644 index 0000000000..34a09a49e5 --- /dev/null +++ b/e2e-tests/playwright/utils/cloudsql-config.ts @@ -0,0 +1,292 @@ +/** + * Google Cloud SQL Auth Proxy helpers for showcase-runtime external DB tests (Helm). + * + * Installs the Auth Proxy as a native sidecar initContainer (`restartPolicy: + * Always` + startupProbe) so the DB tunnel is up before backstage-backend + * starts — matching RHIDP-7007 / RHIDP-12563. Operator coverage is RHIDP-9141. + * + * Reuses runtime-deploy Helm helpers and operator-install-profile ensureRecord. + */ + +import { existsSync, readFileSync } from "fs"; + +import * as yaml from "yaml"; + +import { base64Encode, discoverRouterBase, resolveInstallMethod } from "./helper"; +import { deploymentName as resolveDeploymentName } from "./instance-route-identity"; +import { KubeClient, isRecord } from "./kube-client"; +import { ensureRecord, type YamlRecord } from "./operator-install-profile"; +import { generateHelmValuesYaml, resolveConfig } from "./runtime-config"; +import { upgradeRuntimeHelmRelease } from "./runtime-deploy"; + +export const CLOUD_SQL_PROXY_IMAGE = "gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.21.3"; +export const CLOUD_SQL_SA_SECRET = "cloud-sql-service-account"; +export const CLOUD_SQL_PROXY_CONTAINER = "cloud-sql-proxy"; +export const CLOUD_SQL_VOLUME = "cloud-sql-secret"; + +function asNamedRecordList(value: unknown): YamlRecord[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is YamlRecord => isRecord(item)); +} + +function upsertNamedRecord(list: YamlRecord[], item: YamlRecord): YamlRecord[] { + const name = item.name; + if (typeof name !== "string" || name === "") { + throw new Error("Named pod-spec item requires a string name"); + } + return [...list.filter((entry) => entry.name !== name), item]; +} + +/** SA key volume mounted by the Auth Proxy sidecar. */ +export function buildCloudSqlProxyVolume(): YamlRecord { + return { + name: CLOUD_SQL_VOLUME, + secret: { secretName: CLOUD_SQL_SA_SECRET }, + }; +} + +/** Cloud SQL Auth Proxy native-sidecar initContainer + volume. */ +export function buildCloudSqlProxySidecar(instanceConnectionName: string): { + container: YamlRecord; + volume: YamlRecord; +} { + const container: YamlRecord = { + name: CLOUD_SQL_PROXY_CONTAINER, + image: CLOUD_SQL_PROXY_IMAGE, + // Native sidecar: app containers wait until startupProbe succeeds. + restartPolicy: "Always", + args: [ + "--structured-logs", + "--credentials-file=/secrets/service_account.json", + instanceConnectionName, + ], + env: [ + { name: "CSQL_PROXY_PORT", value: "5432" }, + { name: "CSQL_PROXY_HEALTH_CHECK", value: "true" }, + { name: "CSQL_PROXY_HTTP_PORT", value: "9801" }, + { name: "CSQL_PROXY_HTTP_ADDRESS", value: "0.0.0.0" }, + { name: "CSQL_PROXY_EXIT_ZERO_ON_SIGTERM", value: "true" }, + { name: "CSQL_PROXY_QUITQUITQUIT", value: "true" }, + { name: "CSQL_PROXY_ADMIN_PORT", value: "9092" }, + ], + lifecycle: { + preStop: { + exec: { + command: ["/cloud-sql-proxy", "shutdown", "--admin-port", "9092"], + }, + }, + }, + securityContext: { + runAsNonRoot: true, + readOnlyRootFilesystem: true, + allowPrivilegeEscalation: false, + }, + ports: [{ containerPort: 9801 }], + startupProbe: { + httpGet: { path: "/startup", port: 9801 }, + periodSeconds: 1, + failureThreshold: 60, + }, + livenessProbe: { + httpGet: { path: "/liveness", port: 9801 }, + periodSeconds: 10, + failureThreshold: 3, + }, + resources: { + requests: { memory: "2Gi", cpu: "1" }, + }, + volumeMounts: [ + { + name: CLOUD_SQL_VOLUME, + mountPath: "/secrets/", + readOnly: true, + }, + ], + }; + + return { container, volume: buildCloudSqlProxyVolume() }; +} + +/** + * Create/update the GCP service account key secret used by the Auth Proxy. + */ +export async function createCloudSqlServiceAccountSecret( + kubeClient: KubeClient, + namespace: string, + jsonPath: string, +): Promise { + if (!existsSync(jsonPath)) { + throw new Error(`Cloud SQL service account JSON not found: ${jsonPath}`); + } + const content = readFileSync(jsonPath, "utf-8"); + await kubeClient.createOrUpdateSecret( + { + metadata: { name: CLOUD_SQL_SA_SECRET }, + data: { + "service_account.json": base64Encode(content), + }, + }, + namespace, + ); + console.log(`Secret ${CLOUD_SQL_SA_SECRET} ready in ${namespace}`); +} + +/** + * Helm values overlay: disable local Postgres and wire postgres-cred + SA volume. + * Proxy is applied separately as a native sidecar initContainer on the Deployment + * so we do not replace the chart's install-dynamic-plugins initContainer list. + * + * Also overrides chart-default `POSTGRESQL_ADMIN_PASSWORD` / app-config password + * placeholders: with `upstream.postgresql.enabled=false` the `-postgresql` + * Secret is not created, and leaving those refs causes CreateContainerConfigError. + */ +export function generateCloudSqlHelmValuesOverlay(): string { + const parsed: unknown = yaml.parse(generateHelmValuesYaml()); + if (!isRecord(parsed)) { + throw new TypeError("runtime Helm values: expected a YAML object"); + } + const base = parsed; + const upstream = ensureRecord(base, "upstream"); + const backstage = ensureRecord(upstream, "backstage"); + + backstage.extraVolumes = upsertNamedRecord( + asNamedRecordList(backstage.extraVolumes), + buildCloudSqlProxyVolume(), + ); + // Keep BACKEND_SECRET only — drop chart-default POSTGRESQL_ADMIN_PASSWORD. + backstage.extraEnvVars = [ + { + name: "BACKEND_SECRET", + valueFrom: { + secretKeyRef: { + key: "backend-secret", + name: '{{ include "rhdh.backend-secret-name" $ }}', + }, + }, + }, + ]; + backstage.extraEnvVarsSecrets = ["postgres-cred"]; + + const appConfig = ensureRecord(backstage, "appConfig"); + const backend = ensureRecord(appConfig, "backend"); + backend.database = { + connection: { + host: "${POSTGRES_HOST}", + port: "${POSTGRES_PORT}", + user: "${POSTGRES_USER}", + password: "${POSTGRES_PASSWORD}", + }, + }; + + upstream.postgresql = { enabled: false }; + + return yaml.stringify(base, { lineWidth: 0 }); +} + +/** + * Upsert the Auth Proxy as a native sidecar initContainer on the live Deployment. + * Ensures the proxy is ready before backstage-backend starts (K8s native sidecars). + */ +async function upsertCloudSqlProxyOnDeployment( + kubeClient: KubeClient, + namespace: string, + deploymentName: string, + instanceConnectionName: string, +): Promise { + const response = await kubeClient.appsApi.readNamespacedDeployment(deploymentName, namespace); + const podSpec = response.body.spec?.template?.spec; + if (podSpec === undefined) { + throw new Error(`Deployment ${deploymentName} has no pod spec`); + } + + const { container, volume } = buildCloudSqlProxySidecar(instanceConnectionName); + const initContainers = upsertNamedRecord(asNamedRecordList(podSpec.initContainers), container); + const volumes = upsertNamedRecord(asNamedRecordList(podSpec.volumes), volume); + + // JSON Patch replaces whole arrays so we keep chart initContainers and upsert the proxy. + const patch: object[] = [ + { + op: podSpec.initContainers === undefined ? "add" : "replace", + path: "/spec/template/spec/initContainers", + value: initContainers, + }, + { + op: podSpec.volumes === undefined ? "add" : "replace", + path: "/spec/template/spec/volumes", + value: volumes, + }, + ]; + await kubeClient.jsonPatchDeployment(deploymentName, namespace, patch); + console.log( + `Deployment ${deploymentName}: Auth Proxy native sidecar → ${instanceConnectionName}`, + ); +} + +/** + * Install/update the Auth Proxy native sidecar and disable local Postgres. + * Helm-only (RHIDP-9140): values overlay (no `--wait`) + Deployment initContainer + * patch, then restart. Waiting on helm before the proxy exists leaves the pod + * unable to reach Postgres and times out CreateContainer/Ready. + * Operator coverage (RHIDP-9141) is deferred while operator nightly is broken. + */ +export async function injectCloudSqlSidecar( + kubeClient: KubeClient, + namespace: string, + releaseName: string, + instanceConnectionName: string, +): Promise { + const installMethod = resolveInstallMethod(); + if (installMethod !== "helm") { + throw new Error( + `Cloud SQL Auth Proxy inject is Helm-only (got install method "${installMethod}")`, + ); + } + const deploymentName = resolveDeploymentName(installMethod, releaseName); + + const routerBase = process.env.K8S_CLUSTER_ROUTER_BASE ?? (await discoverRouterBase()); + const config = { ...resolveConfig(routerBase), releaseName, namespace }; + // Apply overlay without --wait: DB is only reachable after the proxy patch. + await upgradeRuntimeHelmRelease(config, generateCloudSqlHelmValuesOverlay(), { + wait: false, + }); + await upsertCloudSqlProxyOnDeployment( + kubeClient, + namespace, + deploymentName, + instanceConnectionName, + ); + + // Rollout: native sidecar startupProbe must pass before backstage-backend is Ready. + await kubeClient.restartDeploymentWithRetry(deploymentName, namespace); +} + +/** + * Rotate the Auth Proxy target instance on the live Helm Deployment + * (postgresql already disabled from prepare). Restart waits until the native + * sidecar startupProbe passes before RHDH is Ready. + */ +export async function configureCloudSqlProxyInstance( + kubeClient: KubeClient, + namespace: string, + releaseName: string, + instanceConnectionName: string, +): Promise { + const installMethod = resolveInstallMethod(); + if (installMethod !== "helm") { + throw new Error( + `Cloud SQL Auth Proxy configure is Helm-only (got install method "${installMethod}")`, + ); + } + const deploymentName = resolveDeploymentName(installMethod, releaseName); + + await upsertCloudSqlProxyOnDeployment( + kubeClient, + namespace, + deploymentName, + instanceConnectionName, + ); + + await kubeClient.restartDeploymentWithRetry(deploymentName, namespace); +} diff --git a/e2e-tests/playwright/utils/common/auth-popup.ts b/e2e-tests/playwright/utils/common/auth-popup.ts index 6309ece1c3..4cbbde3b17 100644 --- a/e2e-tests/playwright/utils/common/auth-popup.ts +++ b/e2e-tests/playwright/utils/common/auth-popup.ts @@ -166,6 +166,9 @@ async function fillMicrosoftCredentials( await popup.locator("[name=passwd]").fill(password, { timeout: 5000 }); await popup.getByRole("button", { name: "Sign in" }).click({ timeout: 5000 }); await popup.getByRole("button", { name: "No" }).click({ timeout: 15000 }); + if (!popup.isClosed()) { + await popup.waitForEvent("close", { timeout: 20_000 }); + } return "Login successful"; } catch (e) { const usernameError = popup.locator("id=usernameError"); @@ -197,17 +200,40 @@ async function fillPingFederateCredentials( ): Promise { /* oxlint-disable playwright/no-raw-locators -- Intentional divergence: third-party PingFederate login popup */ try { - await popup.locator("#username").click(); - await popup.locator("#username").fill(username, { timeout: 5000 }); - await popup.locator("#password").click(); - await popup.locator("#password").fill(password, { timeout: 5000 }); - await popup.locator("#signOnButton").click({ timeout: 5000 }); - await popup.locator("#allowButton").click({ timeout: 10000 }); - await popup.waitForEvent("close", { timeout: 2000 }); + const usernameField = popup.locator("#username"); + const passwordField = popup.locator("#password"); + const signOnButton = popup.locator("#signOnButton, button[type='submit'], #submit"); + const allowButton = popup.locator("#allowButton"); + + await expect(usernameField).toBeVisible({ timeout: 30_000 }); + await usernameField.fill(username); + await expect(passwordField).toBeVisible({ timeout: 10_000 }); + await passwordField.fill(password); + await expect(signOnButton.first()).toBeVisible({ timeout: 10_000 }); + await signOnButton.first().click(); + + // Consent / allow is not always shown (already consented sessions). + // Prefer waiting on the allow control or popup close rather than a fixed sleep. + const allowWait = allowButton + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => "allow" as const) + .catch(() => null); + const closeWait = popup + .waitForEvent("close", { timeout: 15_000 }) + .then(() => "closed" as const) + .catch(() => null); + const allowOrClosed = await Promise.race([allowWait, closeWait]); + + if (allowOrClosed === "allow") { + await allowButton.click({ timeout: 10_000 }); + } + if (allowOrClosed !== "closed" && !popup.isClosed()) { + await popup.waitForEvent("close", { timeout: 90_000 }); + } return "Login successful"; } catch (e) { const errorElement = popup.locator(".ping-error, .error, [role=alert]"); - if (await errorElement.isVisible()) { + if (await errorElement.isVisible().catch(() => false)) { await popup.close(); return "Login failed - invalid credentials"; } @@ -246,7 +272,7 @@ export async function handleKeycloakPopupLogin( await popup.locator("#username").fill(username); await popup.locator("#password").fill(password); await popup.locator("[name=login]").click({ timeout: 5000 }); - await popup.waitForEvent("close", { timeout: 2000 }); + await popup.waitForEvent("close", { timeout: 20_000 }); return "Login successful"; } catch (e) { const usernameError = popup.locator("id=input-error"); diff --git a/e2e-tests/playwright/utils/constants.ts b/e2e-tests/playwright/utils/constants.ts index 03eb98fcd2..5438a88a8d 100644 --- a/e2e-tests/playwright/utils/constants.ts +++ b/e2e-tests/playwright/utils/constants.ts @@ -1,5 +1,5 @@ export const NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE = - /Login failed;u caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver./u; + /Login failed; caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver./u; /** * CI/CD Environment variable patterns used for conditional test execution diff --git a/e2e-tests/playwright/utils/deployment-readiness.ts b/e2e-tests/playwright/utils/deployment-readiness.ts new file mode 100644 index 0000000000..0a8fca53ba --- /dev/null +++ b/e2e-tests/playwright/utils/deployment-readiness.ts @@ -0,0 +1,42 @@ +/** + * Staged deployment readiness: Deployment created → HTTP /healthcheck → catalog synced. + * + * "created" waits only until the Deployment object exists — not K8s Available — + * so HTTP can fail fast on 503 instead of burning the Available timeout first. + */ + +export type DeploymentReadinessStage = "created" | "http" | "synced"; + +export type DeploymentReadinessDeps = { + waitForCreated: () => Promise; + waitForHttpReady: () => Promise; + waitForSynced: () => Promise; +}; + +const STAGE_ORDER: DeploymentReadinessStage[] = ["created", "http", "synced"]; + +const STAGE_RUNNERS: Record< + DeploymentReadinessStage, + (deps: DeploymentReadinessDeps) => Promise +> = { + created: (deps) => deps.waitForCreated(), + http: (deps) => deps.waitForHttpReady(), + synced: (deps) => deps.waitForSynced(), +}; + +/** + * Run readiness stages in fixed order, skipping stages not requested. + */ +export async function waitForDeploymentReadiness( + stages: readonly DeploymentReadinessStage[], + deps: DeploymentReadinessDeps, +): Promise { + const requested = new Set(stages); + + for (const stage of STAGE_ORDER) { + if (!requested.has(stage)) { + continue; + } + await STAGE_RUNNERS[stage](deps); + } +} diff --git a/e2e-tests/playwright/utils/dynamic-plugins-profile.ts b/e2e-tests/playwright/utils/dynamic-plugins-profile.ts new file mode 100644 index 0000000000..4102f3f1b4 --- /dev/null +++ b/e2e-tests/playwright/utils/dynamic-plugins-profile.ts @@ -0,0 +1,80 @@ +/** + * Dynamic plugins profile — owns includes policy and enable semantics. + * + * Operator merge reintroduces defaults when `includes` is omitted. Plugin + * toggles historically mixed `enabled` (YAML) and `disabled` (mutator); + * this module normalizes on `enabled`. + */ + +export interface DynamicPluginEntry { + package: string; + enabled?: boolean; + disabled?: boolean; + pluginConfig?: unknown; +} + +export interface DynamicPluginsProfile { + includes: string[]; + plugins: DynamicPluginEntry[]; +} + +export type MutableDynamicPluginsConfig = { + includes?: string[]; + plugins: DynamicPluginEntry[]; +}; + +const HOMEPAGE_PACKAGE = + "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page"; + +/** Force includes to [] so dynamic-plugins.default.yaml cannot load. */ +export function applyDynamicPluginsProfile(config: T): T { + config.includes = []; + return config; +} + +/** + * Enable or disable a plugin by package name. + * Writes `enabled` and clears `disabled` so schemas cannot disagree. + */ +export function setPluginEnabled( + config: MutableDynamicPluginsConfig, + packageName: string, + enabled: boolean, +): void { + applyDynamicPluginsProfile(config); + + const plugin = config.plugins.find((entry) => entry.package === packageName); + if (plugin === undefined) { + config.plugins = [...config.plugins, { package: packageName, enabled }]; + return; + } + + plugin.enabled = enabled; + delete plugin.disabled; +} + +/** + * Runtime homepage profile (DynamicHomePage on `/`). + * Operator uses this as-is (`includes: []`). Helm overrides `includes` to + * `dynamic-plugins.default.yaml` so install does not race chart Postgres. + */ +export function createRuntimeDynamicPluginsProfile(): DynamicPluginsProfile { + return { + includes: [], + plugins: [ + { + package: HOMEPAGE_PACKAGE, + enabled: true, + pluginConfig: { + dynamicPlugins: { + frontend: { + "red-hat-developer-hub.backstage-plugin-dynamic-home-page": { + dynamicRoutes: [{ path: "/", importName: "DynamicHomePage" }], + }, + }, + }, + }, + }, + ], + }; +} diff --git a/e2e-tests/playwright/utils/errors.ts b/e2e-tests/playwright/utils/errors.ts index 2ef1db332d..dc572650eb 100644 --- a/e2e-tests/playwright/utils/errors.ts +++ b/e2e-tests/playwright/utils/errors.ts @@ -20,3 +20,19 @@ export function hasStatusCode(error: unknown): error is { statusCode: number } { typeof (error as { statusCode: unknown }).statusCode === "number" ); } + +/** True for Kubernetes AlreadyExists / conflict responses (create-or-replace paths). */ +export function isKubernetesConflictError(error: unknown): boolean { + if (hasStatusCode(error) && error.statusCode === 409) { + return true; + } + return hasErrorResponse(error) && error.response?.statusCode === 409; +} + +/** True for Kubernetes NotFound responses (idempotent deletes). */ +export function isKubernetesNotFoundError(error: unknown): boolean { + if (hasStatusCode(error) && error.statusCode === 404) { + return true; + } + return hasErrorResponse(error) && error.response?.statusCode === 404; +} diff --git a/e2e-tests/playwright/utils/instance-readiness.ts b/e2e-tests/playwright/utils/instance-readiness.ts new file mode 100644 index 0000000000..e93e4eff6c --- /dev/null +++ b/e2e-tests/playwright/utils/instance-readiness.ts @@ -0,0 +1,191 @@ +import { type APIRequestContext } from "@playwright/test"; + +import { resolveInstallMethod } from "./helper"; +import { isPredictedRuntimeUrl } from "./instance-route-identity"; +import { ensureRuntimeDeployed } from "./runtime-deploy"; +import { healthcheckRhdhAtUrl } from "./wait-for-rhdh-ready"; + +export type BaseUrlMode = "unset" | "router-stub" | "instance-url"; + +type ReadinessEnv = Record; + +type RequestContextOptions = { + baseURL: string; + ignoreHTTPSErrors: boolean; +}; + +type DisposableRequestContext = { + dispose(): Promise; +}; + +type EnsurePlaywrightReadyDeps = { + env?: ReadinessEnv; + ensureRuntimeDeployed?: () => Promise; + createRequestContext?: (options: RequestContextOptions) => Promise; + waitForRhdhReady?: (request: TContext) => Promise; + resolveInstallMethod?: () => "helm" | "operator"; +}; + +function normalizeEnvValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === "" ? undefined : trimmed; +} + +/** + * Auth-providers CI historically passed only the cluster router base + * (`https://apps.`) as BASE_URL. That is not an RHDH instance. + */ +function isRouterStub(baseUrl: URL, routerBase: string | undefined): boolean { + if ( + baseUrl.username !== "" || + baseUrl.password !== "" || + baseUrl.pathname !== "/" || + baseUrl.search !== "" || + baseUrl.hash !== "" + ) { + return false; + } + + const hostname = baseUrl.hostname.toLowerCase(); + if (routerBase !== undefined && hostname === routerBase.toLowerCase()) { + return true; + } + + // Exact router host only — real RHDH routes are `.apps.`. + return hostname.startsWith("apps."); +} + +export function classifyBaseUrlMode(env: ReadinessEnv): BaseUrlMode { + const baseUrl = normalizeEnvValue(env.BASE_URL); + if (baseUrl === undefined) { + return "unset"; + } + + let parsedBaseUrl: URL; + try { + parsedBaseUrl = new URL(baseUrl); + } catch { + return "instance-url"; + } + + if (parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:") { + return "instance-url"; + } + + return isRouterStub(parsedBaseUrl, normalizeEnvValue(env.K8S_CLUSTER_ROUTER_BASE)) + ? "router-stub" + : "instance-url"; +} + +/** + * RUNTIME_AUTO_DEPLOY must only rewrite / redeploy when the invocation is + * clearly targeting the runtime project. CI should scope the flag to the + * runtime Playwright run; this gate is defense-in-depth against export leaks + * that would otherwise redeploy runtime and stomp BASE_URL for later projects. + */ +export function shouldAutoDeployRuntime( + env: ReadinessEnv, + installMethod: "helm" | "operator" = resolveInstallMethod(), +): boolean { + if (env.RUNTIME_AUTO_DEPLOY !== "true") { + return false; + } + + const mode = classifyBaseUrlMode(env); + if (mode === "unset" || mode === "router-stub") { + return true; + } + + const baseUrl = normalizeEnvValue(env.BASE_URL); + const routerBase = normalizeEnvValue(env.K8S_CLUSTER_ROUTER_BASE); + if (baseUrl === undefined || routerBase === undefined) { + // Predicted instance URL without router base — allow only when hostname + // still matches the runtime formula using router base from the URL itself. + if (baseUrl === undefined) { + return false; + } + try { + const host = new URL(baseUrl).hostname; + const appsIdx = host.indexOf(".apps."); + if (appsIdx === -1) { + return false; + } + const inferredRouterBase = host.slice(appsIdx + 1); + return isPredictedRuntimeUrl(baseUrl, installMethod, inferredRouterBase, env); + } catch { + return false; + } + } + + return isPredictedRuntimeUrl(baseUrl, installMethod, routerBase, env); +} + +async function healthcheckWithDeps( + baseURL: string, + createRequestContext: (options: RequestContextOptions) => Promise, + waitForReady: (request: TContext) => Promise, +): Promise { + const requestContext = await createRequestContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + + try { + await waitForReady(requestContext); + } finally { + await requestContext.dispose(); + } +} + +/** + * Resolve Playwright readiness before any project runs. + * + * Modes: + * - RUNTIME_AUTO_DEPLOY=true targeting runtime → ensure deployed, then healthcheck + * - RUNTIME_AUTO_DEPLOY=true but BASE_URL is a non-runtime instance → healthcheck only + * (ignores the leaked flag) + * - router-stub (without eligible auto-deploy) → no-op + * - instance-url → healthcheck only (CI predeployed) + * - unset → no-op + * + * Runtime CI must still pass a predicted instance URL as BASE_URL so + * `playwright.config.ts` freezes a usable `use.baseURL` before this runs. + */ +export async function ensurePlaywrightReady< + TContext extends DisposableRequestContext = APIRequestContext, +>({ + env = process.env, + ensureRuntimeDeployed: deployRuntime = ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady: waitForReady, + resolveInstallMethod: resolveMethod = resolveInstallMethod, +}: EnsurePlaywrightReadyDeps = {}): Promise { + let baseUrlMode = classifyBaseUrlMode(env); + const autoDeploy = shouldAutoDeployRuntime(env, resolveMethod()); + + if (autoDeploy) { + await deployRuntime(); + baseUrlMode = classifyBaseUrlMode(env); + if (baseUrlMode !== "instance-url") { + throw new Error("Runtime auto-deploy did not produce an instance BASE_URL"); + } + } else if (baseUrlMode === "router-stub") { + return; + } + + if (baseUrlMode !== "instance-url") { + return; + } + + const baseURL = normalizeEnvValue(env.BASE_URL); + if (baseURL === undefined) { + return; + } + + if (createRequestContext !== undefined && waitForReady !== undefined) { + await healthcheckWithDeps(baseURL, createRequestContext, waitForReady); + return; + } + + await healthcheckRhdhAtUrl(baseURL); +} diff --git a/e2e-tests/playwright/utils/instance-route-identity.ts b/e2e-tests/playwright/utils/instance-route-identity.ts new file mode 100644 index 0000000000..fd6b5f3850 --- /dev/null +++ b/e2e-tests/playwright/utils/instance-route-identity.ts @@ -0,0 +1,109 @@ +/** + * Single source of truth for RHDH instance route and deployment naming. + * + * Helm and operator install methods use different route / Deployment names. + * Callers (runtime deploy early-exit, CI predicted URLs, auth operator URL) + * must not invent parallel formulas. + */ + +export type InstallMethod = "helm" | "operator"; + +export type InstanceRouteIdentity = { + installMethod: InstallMethod; + releaseName: string; + namespace: string; + /** OpenShift router base, e.g. `apps.cluster.example.com`. */ + routerBase: string; +}; + +export type InstanceRouteIdentityEnv = Record; + +const DEFAULT_RELEASE_NAME = "rhdh"; +const DEFAULT_RUNTIME_NAMESPACE = "showcase-runtime"; + +export function resolveReleaseName(env: InstanceRouteIdentityEnv = process.env): string { + const releaseName = env.RELEASE_NAME?.trim(); + return releaseName !== undefined && releaseName !== "" ? releaseName : DEFAULT_RELEASE_NAME; +} + +export function resolveRuntimeNamespace(env: InstanceRouteIdentityEnv = process.env): string { + const namespace = env.NAME_SPACE_RUNTIME?.trim(); + return namespace !== undefined && namespace !== "" ? namespace : DEFAULT_RUNTIME_NAMESPACE; +} + +/** + * OpenShift Route object name for the Backstage frontend. + * Helm chart: `-developer-hub` (or the release itself if already suffixed). + * Operator: `backstage-`. + */ +export function routeObjectName(installMethod: InstallMethod, releaseName: string): string { + if (installMethod === "operator") { + return `backstage-${releaseName}`; + } + return releaseName.includes("developer-hub") ? releaseName : `${releaseName}-developer-hub`; +} + +/** + * Kubernetes Deployment name for the Backstage workload. + * Helm: `-developer-hub`. Operator: `backstage-`. + */ +export function deploymentName(installMethod: InstallMethod, releaseName: string): string { + return installMethod === "operator" ? `backstage-${releaseName}` : `${releaseName}-developer-hub`; +} + +/** Predicted OpenShift route hostname (no scheme). */ +export function predictedHostname(identity: InstanceRouteIdentity): string { + const route = routeObjectName(identity.installMethod, identity.releaseName); + return `${route}-${identity.namespace}.${identity.routerBase}`; +} + +/** Predicted https URL for the instance. */ +export function predictedUrl(identity: InstanceRouteIdentity): string { + return `https://${predictedHostname(identity)}`; +} + +export function createInstanceRouteIdentity( + installMethod: InstallMethod, + releaseName: string, + namespace: string, + routerBase: string, +): InstanceRouteIdentity { + return { installMethod, releaseName, namespace, routerBase }; +} + +/** + * Build identity for the showcase-runtime deployment from env + router base. + */ +export function runtimeInstanceRouteIdentity( + installMethod: InstallMethod, + routerBase: string, + env: InstanceRouteIdentityEnv = process.env, +): InstanceRouteIdentity { + return createInstanceRouteIdentity( + installMethod, + resolveReleaseName(env), + resolveRuntimeNamespace(env), + routerBase, + ); +} + +/** + * True when `baseUrl` is the predicted URL for the runtime instance under + * the current env (defense against RUNTIME_AUTO_DEPLOY leaking across projects). + */ +export function isPredictedRuntimeUrl( + baseUrl: string, + installMethod: InstallMethod, + routerBase: string, + env: InstanceRouteIdentityEnv = process.env, +): boolean { + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + return false; + } + + const expected = predictedHostname(runtimeInstanceRouteIdentity(installMethod, routerBase, env)); + return parsed.hostname.toLowerCase() === expected.toLowerCase(); +} diff --git a/e2e-tests/playwright/utils/kube-client/deployment/wait.ts b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts index 1f7a9c9d2a..1473648bf9 100644 --- a/e2e-tests/playwright/utils/kube-client/deployment/wait.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts @@ -117,6 +117,7 @@ async function logDeploymentTimeoutDiagnostics( await diagnostics.logReplicaSetStatus(deploymentName, namespace); await diagnostics.logPodEvents(namespace, finalLabelSelector); await diagnostics.logPodConditions(namespace, finalLabelSelector); + await diagnostics.logPodContainerLogs(namespace, finalLabelSelector, "backstage-backend"); } export async function waitForDeploymentReadyImpl( diff --git a/e2e-tests/playwright/utils/kube-client/helpers.ts b/e2e-tests/playwright/utils/kube-client/helpers.ts index 98a0bea21e..96b5e3639d 100644 --- a/e2e-tests/playwright/utils/kube-client/helpers.ts +++ b/e2e-tests/playwright/utils/kube-client/helpers.ts @@ -2,6 +2,7 @@ import * as k8s from "@kubernetes/client-node"; import { getErrorMessage, hasErrorResponse, hasStatusCode } from "../errors"; import { resolveInstallMethod } from "../helper"; +import { deploymentName, resolveReleaseName } from "../instance-route-identity"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -129,19 +130,18 @@ export function getKubeApiErrorMessage(error: unknown): string { return message === "" ? "Unknown Kubernetes API error" : message; } +/** Wrap a Kubernetes client failure with operation context for CI diagnosis. */ +export function wrapKubernetesError(operation: string, error: unknown): Error { + return new Error(`${operation}: ${getKubeApiErrorMessage(error)}`, { cause: error }); +} + /** * Returns the RHDH deployment name based on the install method. * Uses resolveInstallMethod() which checks INSTALL_METHOD env var first, * then falls back to JOB_NAME pattern matching. */ export function getRhdhDeploymentName(): string { - const releaseName = - process.env.RELEASE_NAME !== undefined && process.env.RELEASE_NAME !== "" - ? process.env.RELEASE_NAME - : "rhdh"; - return resolveInstallMethod() === "operator" - ? `backstage-${releaseName}` - : `${releaseName}-developer-hub`; + return deploymentName(resolveInstallMethod(), resolveReleaseName()); } export function rejectAsError(reject: (reason: Error) => void, err: unknown): void { diff --git a/e2e-tests/playwright/utils/operator-install-profile.ts b/e2e-tests/playwright/utils/operator-install-profile.ts new file mode 100644 index 0000000000..c259c0130f --- /dev/null +++ b/e2e-tests/playwright/utils/operator-install-profile.ts @@ -0,0 +1,90 @@ +/** + * Operator install invariants shared by runtime and auth-provider adapters. + * + * Owns CR apiVersion, empty flavours, BACKEND_SECRET wiring, and operator + * app-config auth-key policy so install methods cannot drift. + */ + +export const BACKSTAGE_CR_API_VERSION = "rhdh.redhat.com/v1alpha5"; + +/** Stable test secret — mirrors the Helm chart's BACKEND_SECRET default. */ +export const OPERATOR_BACKEND_SECRET = "super-secret-for-tests"; + +export type OperatorInstallKind = "runtime" | "auth-providers"; + +export type YamlRecord = Record; + +export interface BackstageCrLike { + apiVersion: string; + kind: string; + metadata: { name: string; [key: string]: unknown }; + spec: YamlRecord; +} + +function isRecord(value: unknown): value is YamlRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Ensure `parent[key]` is a mutable object record (create if missing). */ +export function ensureRecord(parent: YamlRecord, key: string): YamlRecord { + const existing = parent[key]; + if (isRecord(existing)) { + return existing; + } + const created: YamlRecord = {}; + parent[key] = created; + return created; +} + +function ensureEnvList(extraEnvs: YamlRecord): YamlRecord[] { + const existing = extraEnvs.envs; + if (Array.isArray(existing) && existing.every((item) => isRecord(item))) { + return existing; + } + const created: YamlRecord[] = []; + extraEnvs.envs = created; + return created; +} + +function upsertEnv(envs: YamlRecord[], name: string, value: string): void { + const existing = envs.find((env) => env.name === name); + if (existing !== undefined) { + existing.value = value; + return; + } + envs.push({ name, value }); +} + +/** + * Apply shared operator CR invariants (apiVersion, flavours, BACKEND_SECRET). + */ +export function applyOperatorInstallProfileToCr(cr: BackstageCrLike): BackstageCrLike { + cr.apiVersion = BACKSTAGE_CR_API_VERSION; + cr.spec.flavours = []; + + const application = ensureRecord(cr.spec, "application"); + const extraEnvs = ensureRecord(application, "extraEnvs"); + const envs = ensureEnvList(extraEnvs); + upsertEnv(envs, "BACKEND_SECRET", OPERATOR_BACKEND_SECRET); + + return cr; +} + +/** + * Apply shared operator app-config invariants. + * + * Auth-providers fixtures historically used a hardcoded key and sqlite; on + * cluster the operator provisions Postgres and readiness needs ${BACKEND_SECRET}. + */ +export function applyOperatorInstallProfileToAppConfig( + appConfig: YamlRecord, + kind: OperatorInstallKind, +): void { + const backend = ensureRecord(appConfig, "backend"); + const auth = ensureRecord(backend, "auth"); + auth.keys = [{ secret: "${BACKEND_SECRET}" }]; + + if (kind === "auth-providers" && "database" in backend) { + delete backend.database; + } +} diff --git a/e2e-tests/playwright/utils/port-forward.ts b/e2e-tests/playwright/utils/port-forward.ts index 2aea6c10d9..3468a27f57 100644 --- a/e2e-tests/playwright/utils/port-forward.ts +++ b/e2e-tests/playwright/utils/port-forward.ts @@ -18,6 +18,29 @@ export type PortForwardOptions = { stopTimeoutMs?: number; }; +function killProcessTree( + child: ChildProcessByStdio, + signal: NodeJS.Signals, +): void { + const pid = child.pid; + if (pid === undefined) { + return; + } + + // Prefer process-group kill so shell-spawned kubectl/oc children die with the shell. + // Detached sessions on POSIX put the child in its own group (negative PID). + if (process.platform !== "win32") { + try { + process.kill(-pid, signal); + return; + } catch { + // Fall through — group may not exist if spawn was not detached. + } + } + + child.kill(signal); +} + export class PortForwardSession { private child: ChildProcessByStdio | null = null; private readonly output: string[] = []; @@ -35,13 +58,18 @@ export class PortForwardSession { this.output.length = 0; this.outputBuffer = ""; + + // Detach on POSIX so stop() can signal the whole process group (shell + kubectl). + const useProcessGroup = process.platform !== "win32"; const child = "shellCommand" in this.command ? spawn("/bin/sh", ["-c", this.command.shellCommand], { stdio: ["ignore", "pipe", "pipe"], + detached: useProcessGroup, }) : spawn(this.command.command, this.command.args, { stdio: ["ignore", "pipe", "pipe"], + detached: useProcessGroup, }); this.child = child; @@ -116,11 +144,11 @@ export class PortForwardSession { const stopTimeoutMs = this.options.stopTimeoutMs ?? 5_000; const killTimeout = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); + killProcessTree(child, "SIGKILL"); } }, stopTimeoutMs); - child.kill("SIGTERM"); + killProcessTree(child, "SIGTERM"); await once(child, "exit"); clearTimeout(killTimeout); } diff --git a/e2e-tests/playwright/utils/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index de0c52dafc..b804306fcc 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -2,7 +2,7 @@ * PostgreSQL configuration utilities for external database tests. * Provides functions to configure TLS certificates and database credentials * via Kubernetes secrets for testing with external PostgreSQL instances - * (Azure Database for PostgreSQL, Amazon RDS, etc.). + * (Azure Database for PostgreSQL, Amazon RDS, Google Cloud SQL, etc.). * * Certificates are loaded from file paths set by CI pipeline (from Vault). * File paths are used instead of loading content into env vars to avoid @@ -101,6 +101,8 @@ export async function configurePostgresCredentials( POSTGRES_HOST: base64Encode(credentials.host), POSTGRES_PORT: base64Encode(credentials.port ?? "5432"), PGSSLMODE: base64Encode(credentials.sslMode ?? "require"), + // Kept even for Cloud SQL Auth Proxy (PGSSLMODE=disable); satisfies + // ensurePostgresCredEnvVars secretKeyRef keys on the Deployment. NODE_EXTRA_CA_CERTS: base64Encode("/opt/app-root/src/postgres-crt.pem"), }; @@ -130,9 +132,19 @@ const SYSTEM_DATABASES = [ "rdsadmin", "azure_maintenance", "azure_sys", + "cloudsqladmin", ]; -function buildSslConfig(certificatePath: string | undefined): { ca: string } | boolean { +function buildSslConfig( + certificatePath: string | undefined, + ssl?: boolean | { rejectUnauthorized?: boolean }, +): boolean | { ca: string } | { rejectUnauthorized: boolean } { + if (ssl === false) { + return false; + } + if (ssl !== undefined && typeof ssl === "object") { + return { rejectUnauthorized: ssl.rejectUnauthorized ?? false }; + } if (certificatePath === undefined || certificatePath === "") { return true; } @@ -211,8 +223,10 @@ export async function clearDatabase(credentials: { user: string; password: string; certificatePath?: string; + /** Override SSL: false disables TLS; object sets rejectUnauthorized without a CA. */ + ssl?: boolean | { rejectUnauthorized?: boolean }; }): Promise { - console.log("Starting database cleanup process..."); + console.log(`Starting database cleanup for ${credentials.host}...`); const client = new Client({ host: credentials.host, @@ -220,7 +234,7 @@ export async function clearDatabase(credentials: { user: credentials.user, password: credentials.password, database: "postgres", - ssl: buildSslConfig(credentials.certificatePath), + ssl: buildSslConfig(credentials.certificatePath, credentials.ssl), connectionTimeoutMillis: 30 * 1000, query_timeout: 120 * 1000, }); diff --git a/e2e-tests/playwright/utils/runtime-config.ts b/e2e-tests/playwright/utils/runtime-config.ts index 7d23494f8a..d615130c4f 100644 --- a/e2e-tests/playwright/utils/runtime-config.ts +++ b/e2e-tests/playwright/utils/runtime-config.ts @@ -17,17 +17,23 @@ import * as yaml from "yaml"; +import { createRuntimeDynamicPluginsProfile } from "./dynamic-plugins-profile"; import { type ImageRef, buildImageRef, imageRefToString, parseCatalogIndexImage } from "./helper"; import { BACKSTAGE_BACKEND_CONTAINER } from "./kube-client"; +import { + BACKSTAGE_CR_API_VERSION, + OPERATOR_BACKEND_SECRET, + applyOperatorInstallProfileToAppConfig, + applyOperatorInstallProfileToCr, +} from "./operator-install-profile"; + +export { BACKSTAGE_CR_API_VERSION } from "./operator-install-profile"; // ─── Shared constants ──────────────────────────────────────────────────────── const appTitle = "Red Hat Developer Hub"; const dynamicPluginsPvcSize = "5Gi"; -/** Backstage CRD API version — update when the CRD version bumps. */ -export const BACKSTAGE_CR_API_VERSION = "rhdh.redhat.com/v1alpha5"; - // ─── Resolved configuration ───────────────────────────────────────────────── export interface RuntimeDeployConfig { @@ -110,13 +116,19 @@ export function resolveConfig(routerBase: string): RuntimeDeployConfig { * differ from the chart defaults. * * Values omitted (inherited from chart defaults): - * - global.dynamic.{includes, plugins} * - upstream.nameOverride * - upstream.backstage.appConfig.{app.baseUrl, backend.baseUrl, cors, externalAccess} * - upstream.backstage.extraEnvVars (BACKEND_SECRET, POSTGRESQL_ADMIN_PASSWORD) * - upstream.backstage.installDir * - upstream.postgresql.enabled * + * Explicit overrides (not inherited): + * - global.dynamic — DynamicHomePage on / (same pluginConfig as operator) plus + * includes: ['dynamic-plugins.default.yaml'] so install-dynamic-plugins + * still takes long enough for chart Postgres to become ready. Operator uses + * includes: [] safely; Helm with includes: [] alone races and leaves the + * backend on 503 (ECONNREFUSED :5432) through helm --wait. + * * Arrays (extraVolumes, extraVolumeMounts) include chart-default entries * because Helm replaces arrays entirely — we add postgres-crt and change * dynamic-plugins-root from ephemeral to PVC. @@ -133,6 +145,12 @@ export function generateHelmValuesYaml(): string { const values = { global: { lightspeed: { enabled: false }, + // Homepage route for Welcome UI; keep default includes (unlike operator) + // so plugin install does not finish before Postgres is ready. + dynamic: { + ...createRuntimeDynamicPluginsProfile(), + includes: ["dynamic-plugins.default.yaml"], + }, }, upstream: { commonLabels: { "backstage.io/kubernetes-id": "developer-hub" }, @@ -254,7 +272,9 @@ export function generateHelmSetArgs(config: RuntimeDeployConfig): string[] { * Generate the app-config YAML for the operator-deployed runtime RHDH. * * The operator path needs an explicit app-config ConfigMap because it - * doesn't have Helm template helpers for hostname resolution. + * doesn't have Helm template helpers for hostname resolution. Unlike Helm, + * we must also supply `backend.auth.keys` + `BACKEND_SECRET` — chart defaults + * do not apply here, and missing keys leaves the readiness probe at HTTP 503. */ export function generateAppConfigYaml(runtimeUrl: string): string { const appConfig = { @@ -264,12 +284,13 @@ export function generateAppConfigYaml(runtimeUrl: string): string { }, backend: { auth: { + // keys come from OperatorInstallProfile; externalAccess is runtime-specific. externalAccess: [ { type: "legacy", options: { subject: "legacy-default-config", - secret: "secret", + secret: "${BACKEND_SECRET}", }, }, ], @@ -285,6 +306,7 @@ export function generateAppConfigYaml(runtimeUrl: string): string { }, }; + applyOperatorInstallProfileToAppConfig(appConfig, "runtime"); return yaml.stringify(appConfig, { lineWidth: 0 }); } @@ -294,13 +316,20 @@ export function generateAppConfigYaml(runtimeUrl: string): string { * Generate the dynamic-plugins.yaml content for the operator path. * * Runtime tests only need a basic RHDH instance (config-map changes, DB - * connectivity). We set `includes: []` to prevent loading + * connectivity). We set `includes: []` to prevent loading * `dynamic-plugins.default.yaml` — many of its default-enabled plugins * crash without external config (GitHub org, GitLab, LDAP, Keycloak, * ArgoCD, Kubernetes, orchestrator, etc.) and block the readiness probe. + * + * The homepage plugin is explicitly enabled with its frontend wiring + * (dynamicRoutes) so DynamicHomePage renders "Welcome back!" — external DB + * tests verify DB connectivity via the UI. Keeping a non-empty plugins list + * also avoids the operator collapsing empty slices to `{}` on merge. */ export function generateDynamicPluginsYaml(): string { - return yaml.stringify({ includes: [] as string[], plugins: [] as unknown[] }, { lineWidth: 0 }); + // Uses local dist path; switch to OCI ref once runtime deploy + // supports it (see #4909 for the migration direction). + return yaml.stringify(createRuntimeDynamicPluginsProfile(), { lineWidth: 0 }); } // ─── Operator Backstage CR generation ──────────────────────────────────────── @@ -319,6 +348,7 @@ export function generateBackstageCR(config: RuntimeDeployConfig): BackstageCR { { name: "NODE_OPTIONS", value: "--no-node-snapshot" }, { name: "NODE_ENV", value: "production" }, { name: "NODE_TLS_REJECT_UNAUTHORIZED", value: "0" }, + { name: "BACKEND_SECRET", value: OPERATOR_BACKEND_SECRET }, ]; // CATALOG_INDEX_IMAGE override — mirrors the yq injection in @@ -334,8 +364,8 @@ export function generateBackstageCR(config: RuntimeDeployConfig): BackstageCR { }); } - return { - kind: "Backstage", + const cr = { + kind: "Backstage" as const, apiVersion: BACKSTAGE_CR_API_VERSION, metadata: { name: config.releaseName }, spec: { @@ -382,11 +412,9 @@ export function generateBackstageCR(config: RuntimeDeployConfig): BackstageCR { }, route: { enabled: true }, }, - // Disable all default flavours (e.g. lightspeed) to avoid unnecessary - // sidecar containers and init containers that slow down startup and - // restarts. Runtime tests don't need lightspeed — they only test - // ConfigMap changes and DB connectivity. - flavours: [], }, }; + + applyOperatorInstallProfileToCr(cr); + return cr; } diff --git a/e2e-tests/playwright/utils/runtime-deploy.ts b/e2e-tests/playwright/utils/runtime-deploy.ts index 9c6a681192..ad9117427d 100644 --- a/e2e-tests/playwright/utils/runtime-deploy.ts +++ b/e2e-tests/playwright/utils/runtime-deploy.ts @@ -2,9 +2,11 @@ * Runtime deployment utility for SHOWCASE_RUNTIME tests. * * Deploys RHDH with an internal PostgreSQL database via Helm sub-chart - * (helm) or operator-managed StatefulSet (operator). The deployment - * happens once in the first test file's beforeAll — subsequent specs - * reuse the existing deployment since the project runs with workers: 1. + * (helm) or operator-managed StatefulSet (operator). Called from + * Playwright globalSetup when RUNTIME_AUTO_DEPLOY=true (and still safe + * to call from a spec beforeAll — idempotent via a process-local flag + * and an existing-deployment check). Subsequent specs reuse the + * deployment since the project runs with workers: 1. * * All deployment configuration is generated from `runtime-config.ts` — * a single source of truth that produces Helm values YAML, Operator @@ -20,7 +22,7 @@ * K8S_CLUSTER_ROUTER_BASE — cluster router base domain * * Environment variables exported after deployment: - * BASE_URL — RHDH route URL (set only if not already set) + * BASE_URL — RHDH route URL (always set to the instance URL) * SCHEMA_MODE_* — schema-mode env vars (via configureSchemaMode in schema-mode-db.ts) */ @@ -36,10 +38,17 @@ import { discoverRouterBase, imageRefToString, } from "./helper"; +import { + createInstanceRouteIdentity, + deploymentName, + predictedUrl, + routeObjectName, +} from "./instance-route-identity"; import { KubeClient, getErrorStatusCode, getRhdhDeploymentName, + isRecord, waitForBackstageCrd, } from "./kube-client"; import { @@ -119,7 +128,6 @@ async function deployWithHelm( } const { namespace, releaseName } = config; - const { chartUrl, chartVersion } = config.helm; // Create PVC for dynamic plugins — persists extracted plugins across // deployment restarts (config-map and schema-mode tests both restart RHDH). @@ -141,52 +149,14 @@ async function deployWithHelm( } } - // Generate values YAML and write to a temp file const valuesYaml = generateHelmValuesYaml(); - const tmpValuesFile = path.join(os.tmpdir(), `rhdh-runtime-values-${Date.now()}.yaml`); - fs.writeFileSync(tmpValuesFile, valuesYaml, "utf-8"); - console.log(`Generated Helm values written to ${tmpValuesFile}`); + console.log("Installing RHDH via Helm..."); + await upgradeRuntimeHelmRelease(config, valuesYaml); + console.log("Helm install complete"); - try { - // Helm install - const helmArgs = [ - "upgrade", - "-i", - releaseName, - "-n", - namespace, - chartUrl, - "--version", - chartVersion, - "-f", - tmpValuesFile, - ...generateHelmSetArgs(config), - "--wait", - "--timeout", - `${HELM_WAIT_TIMEOUT_MINUTES}m`, - ]; - - console.log("Installing RHDH via Helm..."); - // The process timeout must stay ABOVE helm's own --timeout. When they are - // equal, Node SIGTERMs helm at the same moment helm would have reported - // why it gave up, so the error is truncated to whatever helm had already - // written ("Pulled:", "Digest:") and the actual cause - which pod never - // became ready - is lost on every single failure. - await run("helm", helmArgs, { timeout: HELM_PROCESS_TIMEOUT_MS }); - console.log("Helm install complete"); - } finally { - // Clean up temp file - try { - fs.unlinkSync(tmpValuesFile); - } catch { - // ignore cleanup errors - } - } - - // Read the actual route URL from the cluster - const routeName = releaseName.includes("developer-hub") - ? releaseName - : `${releaseName}-developer-hub`; + // Read the actual route URL from the cluster; fall back to predicted helm URL. + const identity = createInstanceRouteIdentity("helm", releaseName, namespace, config.routerBase); + const routeName = routeObjectName("helm", releaseName); try { const route = await kubeClient.customObjectsApi.getNamespacedCustomObject( "route.openshift.io", @@ -200,7 +170,7 @@ async function deployWithHelm( } catch { // fall through to computed URL } - return `https://${routeName}-${namespace}.${config.routerBase}`; + return predictedUrl(identity); } // --------------------------------------------------------------------------- @@ -212,12 +182,10 @@ async function deployWithOperator( config: ReturnType, ): Promise { const { namespace, releaseName, routerBase } = config; - // The operator creates a route named backstage- whose host is - // backstage--. — this matches the CR's - // spec.application.route.enabled naming convention. Unlike Helm (where - // the chart can customise the route name), the operator's naming is - // deterministic, so a computed URL is sufficient here. - const runtimeUrl = `https://backstage-${releaseName}-${namespace}.${routerBase}`; + // Operator route naming is deterministic — InstanceRouteIdentity owns the formula. + const runtimeUrl = predictedUrl( + createInstanceRouteIdentity("operator", releaseName, namespace, routerBase), + ); // 1. Create app-config ConfigMap (generated from runtime-config.ts) const appConfigYaml = generateAppConfigYaml(runtimeUrl); @@ -268,15 +236,17 @@ async function deployWithOperator( // 6. Wait for the operator to create the deployment console.log("Waiting for operator to create the deployment..."); - const deploymentName = `backstage-${releaseName}`; + const operatorDeploymentName = deploymentName("operator", releaseName); for (let i = 0; i < 60; i++) { try { - await kubeClient.appsApi.readNamespacedDeployment(deploymentName, namespace); - console.log(`Deployment ${deploymentName} found`); + await kubeClient.appsApi.readNamespacedDeployment(operatorDeploymentName, namespace); + console.log(`Deployment ${operatorDeploymentName} found`); break; } catch { if (i === 59) - throw new Error(`Operator did not create deployment ${deploymentName} after 5 minutes`); + throw new Error( + `Operator did not create deployment ${operatorDeploymentName} after 5 minutes`, + ); await new Promise((resolve) => { setTimeout(() => { resolve(); @@ -286,12 +256,127 @@ async function deployWithOperator( } // 7. Wait for deployment readiness - await kubeClient.waitForDeploymentReady(deploymentName, namespace, 1, 600_000); + await kubeClient.waitForDeploymentReady(operatorDeploymentName, namespace, 1, 600_000); console.log("Operator deployment ready"); return runtimeUrl; } +// --------------------------------------------------------------------------- +// Shared Helm / Operator CR helpers (used by runtime deploy + external DB) +// --------------------------------------------------------------------------- + +/** + * `helm upgrade -i` the runtime release with the given values YAML. + * Shared by initial deploy and external-DB overlays (e.g. Cloud SQL). + * + * @param options.wait - When false, skip `--wait` so callers can patch the + * Deployment (e.g. Auth Proxy sidecar) before expecting pods to become Ready. + */ +export async function upgradeRuntimeHelmRelease( + config: ReturnType, + valuesYaml: string, + options: { wait?: boolean } = {}, +): Promise { + if (!config.helm) { + throw new Error("CHART_VERSION environment variable is required for Helm deployment"); + } + + const waitForReady = options.wait !== false; + const { namespace, releaseName } = config; + const { chartUrl, chartVersion } = config.helm; + const tmpValuesFile = path.join(os.tmpdir(), `rhdh-runtime-values-${Date.now()}.yaml`); + fs.writeFileSync(tmpValuesFile, valuesYaml, "utf-8"); + console.log(`Generated Helm values written to ${tmpValuesFile}`); + + const args = [ + "upgrade", + "-i", + releaseName, + "-n", + namespace, + chartUrl, + "--version", + chartVersion, + "-f", + tmpValuesFile, + ...generateHelmSetArgs(config), + ]; + if (waitForReady) { + args.push("--wait", "--timeout", `${HELM_WAIT_TIMEOUT_MINUTES}m`); + } + + try { + // Process timeout must stay above helm --timeout so helm can print why it failed. + await run("helm", args, { + timeout: waitForReady ? HELM_PROCESS_TIMEOUT_MS : 180_000, + }); + } finally { + try { + fs.unlinkSync(tmpValuesFile); + } catch { + // ignore cleanup errors + } + } +} + +function parseBackstageApiVersion(apiVersion: string): { group: string; version: string } { + const [group, version] = apiVersion.split("/"); + if (group === undefined || version === undefined || group === "" || version === "") { + throw new Error(`Invalid Backstage CR apiVersion: ${apiVersion}`); + } + return { group, version }; +} + +function isRuntimeBackstageCr(value: unknown): value is ReturnType { + return ( + isRecord(value) && + value.kind === "Backstage" && + typeof value.apiVersion === "string" && + isRecord(value.metadata) && + typeof value.metadata.name === "string" && + isRecord(value.spec) + ); +} + +/** Read the live Backstage CR for the runtime release. */ +export async function getRuntimeBackstageCr( + kubeClient: KubeClient, + namespace: string, + releaseName: string, +): Promise> { + const { group, version } = parseBackstageApiVersion(BACKSTAGE_CR_API_VERSION); + const response = await kubeClient.customObjectsApi.getNamespacedCustomObject( + group, + version, + namespace, + "backstages", + releaseName, + ); + if (!isRuntimeBackstageCr(response.body)) { + throw new TypeError(`Backstage CR '${releaseName}' has unexpected shape`); + } + return response.body; +} + +/** Replace the live Backstage CR (operator reconciles the Deployment). */ +export async function replaceRuntimeBackstageCr( + kubeClient: KubeClient, + namespace: string, + releaseName: string, + cr: ReturnType, +): Promise { + const { group, version } = parseBackstageApiVersion(cr.apiVersion || BACKSTAGE_CR_API_VERSION); + await kubeClient.customObjectsApi.replaceNamespacedCustomObject( + group, + version, + namespace, + "backstages", + releaseName, + cr, + ); +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -330,14 +415,23 @@ export async function ensureRuntimeDeployed(): Promise { const kubeClient = new KubeClient(); // Check if deployment already exists and is ready - const deploymentName = getRhdhDeploymentName(); + const existingDeploymentName = getRhdhDeploymentName(); + const runtimeUrl = predictedUrl( + createInstanceRouteIdentity(installMethod, releaseName, namespace, routerBase), + ); try { - const dep = await kubeClient.appsApi.readNamespacedDeployment(deploymentName, namespace); + const dep = await kubeClient.appsApi.readNamespacedDeployment( + existingDeploymentName, + namespace, + ); const ready = dep.body.status?.readyReplicas ?? 0; if (ready >= 1) { console.log( - `Deployment ${deploymentName} already running (${ready} ready replicas) — skipping deploy`, + `Deployment ${existingDeploymentName} already running (${ready} ready replicas) — skipping deploy`, ); + // Always publish the instance URL — overwrite router-stub / empty BASE_URL. + process.env.BASE_URL = runtimeUrl; + console.log(`BASE_URL set to ${runtimeUrl}`); deployed = true; // Still configure schema-mode env if not already set if ( @@ -357,18 +451,17 @@ export async function ensureRuntimeDeployed(): Promise { await kubeClient.createNamespace(namespace); await createPlaceholderSecrets(kubeClient, namespace); - let runtimeUrl: string; + let deployedUrl: string; if (installMethod === "helm") { - runtimeUrl = await deployWithHelm(kubeClient, config); + deployedUrl = await deployWithHelm(kubeClient, config); } else { - runtimeUrl = await deployWithOperator(kubeClient, config); + deployedUrl = await deployWithOperator(kubeClient, config); } - // Set BASE_URL if not already set - if (process.env.BASE_URL === undefined || process.env.BASE_URL === "") { - process.env.BASE_URL = runtimeUrl; - console.log(`BASE_URL set to ${runtimeUrl}`); - } + // Always publish the instance URL — overwrite router-stub / empty BASE_URL so + // ensurePlaywrightReady can reclassify after RUNTIME_AUTO_DEPLOY. + process.env.BASE_URL = deployedUrl; + console.log(`BASE_URL set to ${deployedUrl}`); // Configure schema-mode env vars await configureSchemaMode(kubeClient, namespace, releaseName, installMethod); diff --git a/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts b/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts index eb3aea20f0..c41eb73a4a 100644 --- a/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts +++ b/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts @@ -1,32 +1,107 @@ -import { expect, type APIRequestContext } from "@playwright/test"; +import { expect, request as playwrightRequest, type APIRequestContext } from "@playwright/test"; + +/** Default timeout for globalSetup / short healthchecks. */ +export const RHDH_READY_DEFAULT_TIMEOUT_MS = 120_000; + +/** Auth-provider deploy budget — matches K8s deployment wait. */ +export const RHDH_READY_DEPLOY_TIMEOUT_MS = 600_000; /** True when a /healthcheck response indicates the RHDH JSON health endpoint. */ export function isJsonHealthcheckResponse(status: number, contentType: string): boolean { return status === 200 && contentType.includes("json"); } +export type HealthcheckProbe = { + ok: boolean; + detail: string; +}; + +/** Minimal HTTP surface for one /healthcheck GET (Playwright APIRequestContext satisfies this). */ +export type HealthcheckHttpClient = { + get: (url: string) => Promise<{ + status: () => number; + headers: () => Record; + json: () => Promise; + }>; +}; + +/** + * Single /healthcheck attempt. Never throws — transport failures (Route down, + * Playwright request timeout) are `{ ok: false }` so expect.poll can retry for + * the full readiness budget instead of aborting on the first TimeoutError. + */ +export async function probeHealthcheck(request: HealthcheckHttpClient): Promise { + let response; + try { + response = await request.get("/healthcheck"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, detail: `request failed: ${message}` }; + } + + const status = response.status(); + const contentType = response.headers()["content-type"] ?? ""; + + if (status === 503) { + return { ok: false, detail: "HTTP 503 from /healthcheck (backend not ready)" }; + } + + if (!isJsonHealthcheckResponse(status, contentType)) { + return { + ok: false, + detail: `HTTP ${status} content-type=${contentType || "(none)"}`, + }; + } + + let body: unknown; + try { + body = await response.json(); + } catch { + return { ok: false, detail: `HTTP ${status} JSON parse failed` }; + } + + if (typeof body === "object" && body !== null && Reflect.get(body, "status") === "ok") { + return { ok: true, detail: "status ok" }; + } + + return { ok: false, detail: `HTTP ${status} unexpected body` }; +} + /** Poll the RHDH instance health endpoint until it responds OK. */ export async function waitForRhdhReady( request: APIRequestContext, - timeoutMs = 120_000, + timeoutMs = RHDH_READY_DEFAULT_TIMEOUT_MS, +): Promise { + let lastDetail = "no response yet"; + + try { + await expect + .poll( + async () => { + const probe = await probeHealthcheck(request); + lastDetail = probe.detail; + return probe.ok; + }, + { timeout: timeoutMs, intervals: [2_000] }, + ) + .toBe(true); + } catch (error) { + throw new Error(`RHDH not ready within ${timeoutMs}ms: ${lastDetail}`, { cause: error }); + } +} + +/** Open a request context, poll /healthcheck, then dispose. */ +export async function healthcheckRhdhAtUrl( + baseURL: string, + timeoutMs = RHDH_READY_DEFAULT_TIMEOUT_MS, ): Promise { - await expect - .poll( - async () => { - const response = await request.get("/healthcheck"); - const contentType = response.headers()["content-type"] ?? ""; - if (!isJsonHealthcheckResponse(response.status(), contentType)) { - return false; - } - let body: unknown; - try { - body = await response.json(); - } catch { - return false; - } - return typeof body === "object" && body !== null && Reflect.get(body, "status") === "ok"; - }, - { timeout: timeoutMs, intervals: [2_000] }, - ) - .toBe(true); + const requestContext = await playwrightRequest.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + try { + await waitForRhdhReady(requestContext, timeoutMs); + } finally { + await requestContext.dispose(); + } } diff --git a/e2e-tests/unit/auth-config-isolation.test.ts b/e2e-tests/unit/auth-config-isolation.test.ts new file mode 100644 index 0000000000..b5973dc3da --- /dev/null +++ b/e2e-tests/unit/auth-config-isolation.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isRetryableConnectionError } from "../playwright/support/auth/provider-auth"; +import { + configureGithubSessionDuration, + configureMicrosoftSessionDuration, + configureOidcAutologout, + configureOidcSessionDuration, + type AuthConfigActions, +} from "../playwright/utils/authentication-providers/rhdh-deployment/auth"; +import { isKubernetesConflictError, isKubernetesNotFoundError } from "../playwright/utils/errors"; +import { wrapKubernetesError } from "../playwright/utils/kube-client/helpers"; + +function captureActions(): AuthConfigActions & { + props: Record; + deleted: string[]; +} { + const props: Record = {}; + const deleted: string[] = []; + return { + props, + deleted, + setDynamicPluginEnabled: vi.fn<(pluginName: string, enabled: boolean) => void>(), + setAppConfigProperty: (path: string, value: unknown): void => { + props[path] = value; + }, + deleteAppConfigProperty: (path: string): void => { + deleted.push(path); + delete props[path]; + }, + }; +} + +describe("isKubernetesConflictError", () => { + it("detects top-level statusCode 409 from the Kubernetes client HttpError", () => { + expect(isKubernetesConflictError({ statusCode: 409 })).toBe(true); + }); + + it("detects nested response.statusCode 409", () => { + expect(isKubernetesConflictError({ response: { statusCode: 409 } })).toBe(true); + }); + + it("rejects non-conflict errors", () => { + expect(isKubernetesConflictError({ statusCode: 500 })).toBe(false); + expect(isKubernetesConflictError(new Error("HTTP request failed"))).toBe(false); + }); +}); + +describe("isKubernetesNotFoundError", () => { + it("detects top-level and nested 404", () => { + expect(isKubernetesNotFoundError({ statusCode: 404 })).toBe(true); + expect(isKubernetesNotFoundError({ response: { statusCode: 404 } })).toBe(true); + }); + + it("rejects non-404 errors", () => { + expect(isKubernetesNotFoundError({ statusCode: 409 })).toBe(false); + }); +}); + +describe("wrapKubernetesError", () => { + it("includes operation context and status detail", () => { + const wrapped = wrapKubernetesError("Failed to delete pod foo", { + response: { statusCode: 500, statusMessage: "Internal Server Error" }, + }); + expect(wrapped.message).toContain("Failed to delete pod foo"); + expect(wrapped.message).toContain("HTTP 500"); + expect(wrapped.cause).toEqual({ + response: { statusCode: 500, statusMessage: "Internal Server Error" }, + }); + }); +}); + +describe("isRetryableConnectionError", () => { + it("retries connection drops and network changes", () => { + expect(isRetryableConnectionError(new Error("net::ERR_CONNECTION_REFUSED"))).toBe(true); + expect(isRetryableConnectionError(new Error("net::ERR_NETWORK_CHANGED"))).toBe(true); + }); + + it("retries ERR_ABORTED only for page.goto navigations", () => { + expect( + isRetryableConnectionError( + new Error( + 'page.goto: net::ERR_ABORTED at https://example.com/\nCall log:\n - navigating to "https://example.com/"', + ), + ), + ).toBe(true); + expect(isRetryableConnectionError(new Error("net::ERR_ABORTED"))).toBe(false); + expect(isRetryableConnectionError(new Error("AbortError: The operation was aborted"))).toBe( + false, + ); + }); + + it("does not retry unrelated navigation failures", () => { + expect(isRetryableConnectionError(new Error("net::ERR_NAME_NOT_RESOLVED"))).toBe(false); + expect(isRetryableConnectionError(new Error("Timeout 30000ms exceeded"))).toBe(false); + }); +}); + +describe("configure*SessionDuration", () => { + it("pins a github username resolver with sessionDuration so earlier resolvers cannot leak", () => { + const actions = captureActions(); + configureGithubSessionDuration(actions, "3days"); + + expect(actions.props["auth.providers.github.production.signIn.resolvers"]).toEqual([ + { + resolver: "usernameMatchingUserEntityName", + dangerouslyAllowSignInWithoutUserInCatalog: false, + }, + ]); + expect(actions.props["auth.providers.github.production.sessionDuration"]).toBe("3days"); + }); + + it("pins an oidc profile-email resolver with sessionDuration", () => { + const actions = captureActions(); + configureOidcSessionDuration(actions, "3days"); + + expect(actions.props["auth.providers.oidc.production.signIn.resolvers"]).toEqual([ + { + resolver: "emailMatchingUserEntityProfileEmail", + dangerouslyAllowSignInWithoutUserInCatalog: false, + }, + ]); + expect(actions.props["auth.providers.oidc.production.sessionDuration"]).toBe("3days"); + }); + + it("pins a microsoft profile-email resolver with sessionDuration", () => { + const actions = captureActions(); + configureMicrosoftSessionDuration(actions, "3days"); + + expect(actions.props["auth.providers.microsoft.production.signIn.resolvers"]).toEqual([ + { + resolver: "emailMatchingUserEntityProfileEmail", + dangerouslyAllowSignInWithoutUserInCatalog: false, + }, + ]); + expect(actions.props["auth.providers.microsoft.production.sessionDuration"]).toBe("3days"); + }); +}); + +describe("configureOidcAutologout", () => { + it("pins email resolver, enables autologout, and clears leftover sessionDuration", () => { + const actions = captureActions(); + actions.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); + + configureOidcAutologout(actions, { + idleTimeoutMinutes: 0.5, + promptBeforeIdleSeconds: 5, + }); + + expect(actions.props["auth.providers.oidc.production.signIn.resolvers"]).toEqual([ + { + resolver: "emailMatchingUserEntityProfileEmail", + dangerouslyAllowSignInWithoutUserInCatalog: false, + }, + ]); + expect(actions.deleted).toContain("auth.providers.oidc.production.sessionDuration"); + expect(actions.props["auth.providers.oidc.production.sessionDuration"]).toBeUndefined(); + expect(actions.props["auth.autologout.enabled"]).toBe(true); + expect(actions.props["auth.autologout.idleTimeoutMinutes"]).toBe(0.5); + expect(actions.props["auth.autologout.promptBeforeIdleSeconds"]).toBe(5); + }); +}); diff --git a/e2e-tests/unit/auth-cookie-duration.test.ts b/e2e-tests/unit/auth-cookie-duration.test.ts new file mode 100644 index 0000000000..602e8862e2 --- /dev/null +++ b/e2e-tests/unit/auth-cookie-duration.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { + THREE_DAYS_MS, + refreshTokenRemainingMs, +} from "../playwright/utils/authentication-providers/auth-cookie-duration"; + +describe("refreshTokenRemainingMs", () => { + it("converts Playwright cookie expires (unix seconds) to remaining ms", () => { + const now = Date.UTC(2026, 0, 1); + const expiresUnixSeconds = Math.floor((now + THREE_DAYS_MS) / 1000); + + expect(refreshTokenRemainingMs({ expires: expiresUnixSeconds }, now)).toBe(THREE_DAYS_MS); + }); +}); diff --git a/e2e-tests/unit/auth-deployment-wait.test.ts b/e2e-tests/unit/auth-deployment-wait.test.ts new file mode 100644 index 0000000000..02d45d81f2 --- /dev/null +++ b/e2e-tests/unit/auth-deployment-wait.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + tryGetDeploymentGeneration, + waitForDeploymentCreated, +} from "../playwright/utils/authentication-providers/rhdh-deployment/wait"; + +function mockState(items: Array<{ metadata?: { generation?: number } }>) { + return { + instanceName: "rhdh", + namespace: "showcase-auth-providers", + isRunningLocal: false, + appsV1Api: { + listNamespacedDeployment: vi + .fn<() => Promise<{ body: { items: typeof items } }>>() + .mockResolvedValue({ body: { items } }), + }, + }; +} + +describe("tryGetDeploymentGeneration", () => { + it("returns undefined when the Backstage deployment does not exist yet", async () => { + await expect(tryGetDeploymentGeneration(mockState([]))).resolves.toBeUndefined(); + }); + + it("returns the deployment generation when present", async () => { + await expect( + tryGetDeploymentGeneration(mockState([{ metadata: { generation: 3 } }])), + ).resolves.toBe(3); + }); +}); + +describe("waitForDeploymentCreated", () => { + it("resolves once the labeled Deployment appears", async () => { + const listNamespacedDeployment = vi + .fn<() => Promise<{ body: { items: Array<{ metadata?: { generation?: number } }> } }>>() + .mockResolvedValueOnce({ body: { items: [] } }) + .mockResolvedValueOnce({ body: { items: [{ metadata: { generation: 1 } }] } }); + + await waitForDeploymentCreated( + { + instanceName: "rhdh", + namespace: "showcase-auth-providers", + isRunningLocal: false, + appsV1Api: { listNamespacedDeployment }, + }, + 5_000, + ); + + expect(listNamespacedDeployment.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/e2e-tests/unit/auth-error-messages.test.ts b/e2e-tests/unit/auth-error-messages.test.ts new file mode 100644 index 0000000000..0e0ca2e604 --- /dev/null +++ b/e2e-tests/unit/auth-error-messages.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../playwright/utils/constants"; + +/** Fixture shaped like the Backstage sign-in alert for unresolved catalog users. */ +const UI_NO_USER_ALERT = + "Login failed; caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver."; + +describe("NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE", () => { + it("matches the Backstage unresolved-identity alert text", () => { + expect(UI_NO_USER_ALERT).toMatch(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + }); + + it("does not require a literal ';u' typo from a bad unicode-flag edit", () => { + expect(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE.source).toContain("Login failed; caused by"); + expect(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE.source).not.toContain("Login failed;u caused"); + }); +}); diff --git a/e2e-tests/unit/auth-instance-deployer.test.ts b/e2e-tests/unit/auth-instance-deployer.test.ts new file mode 100644 index 0000000000..91aa1f50b5 --- /dev/null +++ b/e2e-tests/unit/auth-instance-deployer.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + deployAuthInstance, + reconcileAuthInstance, + type AuthDeploymentPort, + type AuthInstanceDeployerHost, +} from "../playwright/utils/authentication-providers/auth-instance-deployer"; +import { RHDH_READY_DEPLOY_TIMEOUT_MS } from "../playwright/utils/wait-for-rhdh-ready"; + +const healthcheckRhdhAtUrl = vi.hoisted(() => + vi.fn<(baseURL: string, timeoutMs?: number) => Promise>().mockResolvedValue(), +); + +vi.mock("../playwright/utils/wait-for-rhdh-ready", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + healthcheckRhdhAtUrl, + }; +}); + +function createHost(isRunningLocal: boolean): AuthInstanceDeployerHost & { calls: string[] } { + const calls: string[] = []; + const deployment: AuthDeploymentPort = { + isRunningLocal, + addSecretData: (key: string) => { + calls.push(`secret:${key}`); + return Promise.resolve(); + }, + setAppConfigProperty: (path: string, value: unknown) => { + calls.push(`setAppConfigProperty:${path}:${String(value)}`); + }, + updateAllConfigs: () => { + calls.push("updateAllConfigs"); + return Promise.resolve(); + }, + createBackstageDeployment: (options?: { waitForReady?: boolean }) => { + calls.push(`createBackstageDeployment:${String(options?.waitForReady)}`); + return Promise.resolve(); + }, + waitForDeploymentCreated: () => { + calls.push("waitForDeploymentCreated"); + return Promise.resolve(); + }, + waitForSynced: () => { + calls.push("waitForSynced"); + return Promise.resolve(); + }, + waitUntilAuthConfigLive: (marker: string) => { + calls.push(`waitUntilAuthConfigLive:${marker}`); + return Promise.resolve(); + }, + restartLocalDeployment: () => { + calls.push("restartLocalDeployment"); + return Promise.resolve(); + }, + }; + + return { + calls, + deployment, + backstageUrl: "https://rhdh.example.test", + backstageBackendUrl: "https://rhdh.example.test", + expectEnvVars: () => { + calls.push("expectEnvVars"); + }, + loadConfigsAndProvisionNamespace: () => { + calls.push("loadConfigs"); + return Promise.resolve("fresh" as const); + }, + addBaseUrlSecretsIfRemote: () => { + calls.push("addBaseUrlSecrets"); + return Promise.resolve(); + }, + addSecretsFromEnv: () => { + calls.push("addSecretsFromEnv"); + return Promise.resolve(); + }, + createSecret: () => { + calls.push("createSecret"); + return Promise.resolve(); + }, + }; +} + +describe("deployAuthInstance", () => { + it("does not put BACKEND_SECRET in rhdh-secrets (CR env owns it)", async () => { + const host = createHost(true); + await deployAuthInstance(host, { + requiredEnvVars: ["FOO"], + enableProvider: () => { + host.calls.push("enableProvider"); + return Promise.resolve(); + }, + }); + + expect(host.calls).not.toContain("secret:BACKEND_SECRET"); + }); + + it("returns only the instance URL (no unused reconcile handle)", async () => { + const host = createHost(true); + const result = await deployAuthInstance(host, { + requiredEnvVars: ["FOO"], + enableProvider: () => Promise.resolve(), + }); + + expect(result).toEqual({ url: "https://rhdh.example.test" }); + expect(result).not.toHaveProperty("reconcile"); + }); + + it("stages created → synced locally without HTTP", async () => { + const host = createHost(true); + await deployAuthInstance(host, { + requiredEnvVars: ["FOO"], + enableProvider: () => { + host.calls.push("enableProvider"); + return Promise.resolve(); + }, + }); + + expect(host.calls).toEqual([ + "expectEnvVars", + "loadConfigs", + "addBaseUrlSecrets", + "createSecret", + "enableProvider", + "updateAllConfigs", + "createBackstageDeployment:false", + "waitForDeploymentCreated", + "waitForSynced", + ]); + expect(healthcheckRhdhAtUrl).not.toHaveBeenCalled(); + }); + + it("stages created → HTTP → synced on the remote CI path", async () => { + const host = createHost(false); + healthcheckRhdhAtUrl.mockClear(); + healthcheckRhdhAtUrl.mockImplementation(() => { + host.calls.push("http"); + return Promise.resolve(); + }); + await deployAuthInstance(host, { + requiredEnvVars: ["FOO"], + enableProvider: () => { + host.calls.push("enableProvider"); + return Promise.resolve(); + }, + }); + + expect(host.calls).toEqual([ + "expectEnvVars", + "loadConfigs", + "addBaseUrlSecrets", + "createSecret", + "enableProvider", + "updateAllConfigs", + "createBackstageDeployment:false", + "waitForDeploymentCreated", + "http", + "waitForSynced", + ]); + expect(healthcheckRhdhAtUrl).toHaveBeenCalledExactlyOnceWith( + "https://rhdh.example.test", + RHDH_READY_DEPLOY_TIMEOUT_MS, + ); + }); + + it("reuses a healthy namespace by re-applying baseline and reconciling instead of NS wipe", async () => { + const host = createHost(false); + host.loadConfigsAndProvisionNamespace = () => { + host.calls.push("loadConfigs:reused"); + return Promise.resolve("reused"); + }; + healthcheckRhdhAtUrl.mockClear(); + healthcheckRhdhAtUrl.mockImplementation(() => { + host.calls.push("http"); + return Promise.resolve(); + }); + + await deployAuthInstance(host, { + requiredEnvVars: ["FOO"], + enableProvider: () => { + host.calls.push("enableProvider"); + return Promise.resolve(); + }, + }); + + expect(host.calls).toEqual([ + "expectEnvVars", + "loadConfigs:reused", + "addBaseUrlSecrets", + "createSecret", + "enableProvider", + "updateAllConfigs", + "createBackstageDeployment:false", + expect.stringMatching(/^setAppConfigProperty:app\.title:e2e-auth-config-/u), + "updateAllConfigs", + "restartLocalDeployment", + expect.stringMatching(/^waitUntilAuthConfigLive:e2e-auth-config-/u), + "http", + "waitForSynced", + ]); + expect(host.calls).not.toContain("waitForDeploymentCreated"); + }); +}); + +describe("reconcileAuthInstance", () => { + it("stamps a title marker, restarts, proves live, then HTTP only by default", async () => { + const host = createHost(false); + healthcheckRhdhAtUrl.mockClear(); + healthcheckRhdhAtUrl.mockImplementation(() => { + host.calls.push("http"); + return Promise.resolve(); + }); + + await reconcileAuthInstance(host); + + expect(host.calls[0]).toMatch(/^setAppConfigProperty:app\.title:e2e-auth-config-/u); + expect(host.calls.slice(1, 4)).toEqual([ + "updateAllConfigs", + "restartLocalDeployment", + expect.stringMatching(/^waitUntilAuthConfigLive:e2e-auth-config-/u), + ]); + expect(host.calls).toContain("http"); + expect(host.calls).not.toContain("waitForSynced"); + expect(host.calls).not.toContain("waitForDeploymentCreated"); + }); + + it("opts into catalog sync when waitForCatalogSync is true", async () => { + const host = createHost(false); + healthcheckRhdhAtUrl.mockClear(); + healthcheckRhdhAtUrl.mockImplementation(() => { + host.calls.push("http"); + return Promise.resolve(); + }); + + await reconcileAuthInstance(host, { waitForCatalogSync: true }); + + expect(host.calls).toContain("http"); + expect(host.calls).toContain("waitForSynced"); + }); + + it("skips remote HTTP on local path but still proves config live", async () => { + const host = createHost(true); + healthcheckRhdhAtUrl.mockClear(); + + await reconcileAuthInstance(host); + + expect(host.calls[0]).toMatch(/^setAppConfigProperty:app\.title:e2e-auth-config-/u); + expect(host.calls).toContain("restartLocalDeployment"); + expect(host.calls.some((c) => c.startsWith("waitUntilAuthConfigLive:"))).toBe(true); + expect(host.calls).not.toContain("waitForSynced"); + expect(healthcheckRhdhAtUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/e2e-tests/unit/catalog-ingestion.test.ts b/e2e-tests/unit/catalog-ingestion.test.ts new file mode 100644 index 0000000000..4e9419ac1b --- /dev/null +++ b/e2e-tests/unit/catalog-ingestion.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +import { API_REQUEST_TIMEOUT_MS, CATALOG_API_TIMEOUT_MS } from "../playwright/utils/api-helper"; +import { parseJsonResponse, type JsonHttpResponse } from "../playwright/utils/api-helper/guards"; +import { + CATALOG_INGESTION_POLL_TIMEOUT_MS, + catalogDisplayNamesInclude, +} from "../playwright/utils/authentication-providers/rhdh-deployment/catalog"; +import { + getCatalogGroups, + getCatalogUsers, +} from "../playwright/utils/authentication-providers/rhdh-deployment/types"; + +describe("catalogDisplayNamesInclude", () => { + it("returns true when every expected name is present", () => { + expect( + catalogDisplayNamesInclude( + ["Admin E2e", "Zeus Giove", "Atena Minerva"], + ["Admin E2e", "Zeus Giove"], + ), + ).toBe(true); + }); + + it("returns false when any expected name is missing", () => { + expect(catalogDisplayNamesInclude(["Admin E2e"], ["Admin E2e", "Zeus Giove"])).toBe(false); + }); +}); + +describe("catalog API budgets", () => { + it("locks the shared API request timeout at 60s", () => { + expect(API_REQUEST_TIMEOUT_MS).toBe(60_000); + expect(CATALOG_API_TIMEOUT_MS).toBe(60_000); + }); + + it("locks ingestion poll budget at 120s", () => { + expect(CATALOG_INGESTION_POLL_TIMEOUT_MS).toBe(120_000); + expect(CATALOG_INGESTION_POLL_TIMEOUT_MS).toBeGreaterThan(API_REQUEST_TIMEOUT_MS); + }); +}); + +function mockJsonHttpResponse(partial: { + ok: boolean; + status: number; + url: string; + text?: string; + json?: unknown; +}): { response: JsonHttpResponse; json: ReturnType Promise>> } { + const json = vi.fn<() => Promise>().mockResolvedValue(partial.json); + return { + response: { + ok: () => partial.ok, + status: () => partial.status, + url: () => partial.url, + text: () => Promise.resolve(partial.text ?? ""), + json, + }, + json, + }; +} + +describe("parseJsonResponse", () => { + it("fails fast on non-2xx so polls do not burn the full timeout", async () => { + const { response, json } = mockJsonHttpResponse({ + ok: false, + status: 503, + url: "https://example.test/api/catalog/entities/by-query", + text: "backend not ready", + }); + + await expect(parseJsonResponse(response)).rejects.toThrow(/HTTP 503/u); + expect(json).not.toHaveBeenCalled(); + }); + + it("parses JSON when the response is ok", async () => { + const { response } = mockJsonHttpResponse({ + ok: true, + status: 200, + url: "https://example.test/api/catalog/entities/by-query", + json: { items: [] }, + }); + + await expect(parseJsonResponse(response)).resolves.toEqual({ items: [] }); + }); +}); + +describe("getCatalogUsers / getCatalogGroups", () => { + it("treats an empty items list as not ingested yet", () => { + expect(getCatalogUsers({ items: [] })).toEqual([]); + expect(getCatalogGroups({ items: [] })).toEqual([]); + }); + + it("fails fast on missing items so polls do not burn the full timeout", () => { + expect(() => getCatalogUsers({ error: "boom" })).toThrow(/Invalid catalog users response/u); + expect(() => getCatalogGroups("not-json")).toThrow(/Invalid catalog groups response/u); + }); + + it("fails fast when items are the wrong entity kind", () => { + expect(() => getCatalogUsers({ items: [{ kind: "Group" }] })).toThrow(/non-User/u); + expect(() => getCatalogGroups({ items: [{ kind: "User" }] })).toThrow(/non-Group/u); + }); +}); diff --git a/e2e-tests/unit/ci-lightspeed-values.test.ts b/e2e-tests/unit/ci-lightspeed-values.test.ts new file mode 100644 index 0000000000..8e6da377cf --- /dev/null +++ b/e2e-tests/unit/ci-lightspeed-values.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; + +import { isRecord } from "../playwright/utils/kube-client/helpers"; + +/** + * Seam: CI helm value files must disable lightspeed inherit packages so + * install-dynamic-plugins cannot InstallException (#4791 / #4869 regression). + * Post-#5021 the field is `enabled: false` (not legacy `disabled: true`). + */ +const valueFilesDir = join(import.meta.dirname, "../../.ci/pipelines/value_files"); + +/** Exact package prefixes the installer matches after helm renders inherit. */ +const lightspeedPackages = [ + "oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed:{{", + "oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-lightspeed-backend:{{", +] as const; + +function pluginsFromValuesFile(fileName: string): Array> { + const doc: unknown = parseYaml(readFileSync(join(valueFilesDir, fileName), "utf8")); + if (!isRecord(doc) || !isRecord(doc.global) || !isRecord(doc.global.dynamic)) { + throw new TypeError(`${fileName}: missing global.dynamic`); + } + const plugins = doc.global.dynamic.plugins; + if (!Array.isArray(plugins)) { + throw new TypeError(`${fileName}: global.dynamic.plugins is not an array`); + } + return plugins.filter((plugin): plugin is Record => isRecord(plugin)); +} + +describe("CI helm value lightspeed overrides", () => { + it.each(["values_showcase.yaml", "values_showcase-rbac.yaml"] as const)( + "sets enabled: false on lightspeed inherit packages in %s", + (fileName) => { + const plugins = pluginsFromValuesFile(fileName); + for (const prefix of lightspeedPackages) { + const match = plugins.find( + (plugin) => typeof plugin.package === "string" && plugin.package.startsWith(prefix), + ); + expect(match, `${fileName} missing override for ${prefix}`).toBeDefined(); + expect(match?.enabled, `${fileName} ${prefix} must set enabled: false`).toBe(false); + expect(match?.disabled, `${fileName} must not use legacy disabled`).toBeUndefined(); + // Helm-escaped inherit tag in source values (renders to :{{inherit}}). + expect(String(match?.package)).toMatch(/inherit/u); + } + }, + ); +}); diff --git a/e2e-tests/unit/ci-operator-start-crs.test.ts b/e2e-tests/unit/ci-operator-start-crs.test.ts new file mode 100644 index 0000000000..6a24bd3e3c --- /dev/null +++ b/e2e-tests/unit/ci-operator-start-crs.test.ts @@ -0,0 +1,48 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; + +import { isRecord } from "../playwright/utils/kube-client/helpers"; + +/** + * Seam: CI operator Backstage start CRs must pin empty flavours — the same + * invariant OperatorInstallProfile owns for Playwright-generated CRs — so the + * lightspeed flavour cannot reinject broken OCI pulls into nightlies. + */ +const operatorCrDir = join(import.meta.dirname, "../../.ci/pipelines/resources/rhdh-operator"); + +interface BackstageCrDoc { + kind: "Backstage"; + spec: { flavours?: unknown }; +} + +function isBackstageCrDoc(value: unknown): value is BackstageCrDoc { + if (!isRecord(value) || value.kind !== "Backstage" || !isRecord(value.spec)) { + return false; + } + return true; +} + +function listOperatorStartCrFiles(): string[] { + return readdirSync(operatorCrDir) + .filter((name) => name.startsWith("rhdh-start") && name.endsWith(".yaml")) + .map((name) => join(operatorCrDir, name)); +} + +describe("CI operator start Backstage CRs", () => { + it("declares flavours: [] on every rhdh-start*.yaml", () => { + const files = listOperatorStartCrFiles(); + expect(files.length).toBeGreaterThan(0); + + for (const file of files) { + const doc: unknown = parseYaml(readFileSync(file, "utf8")); + expect(isBackstageCrDoc(doc), `Backstage doc missing in ${file}`).toBe(true); + if (!isBackstageCrDoc(doc)) { + return; + } + expect(doc.spec.flavours, `${file} must pin flavours: []`).toEqual([]); + } + }); +}); diff --git a/e2e-tests/unit/cloudsql-config.test.ts b/e2e-tests/unit/cloudsql-config.test.ts new file mode 100644 index 0000000000..b477ab856d --- /dev/null +++ b/e2e-tests/unit/cloudsql-config.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; + +import { + CLOUD_SQL_PROXY_CONTAINER, + CLOUD_SQL_PROXY_IMAGE, + CLOUD_SQL_SA_SECRET, + CLOUD_SQL_VOLUME, + buildCloudSqlProxySidecar, + buildCloudSqlProxyVolume, + generateCloudSqlHelmValuesOverlay, +} from "../playwright/utils/cloudsql-config"; +import { isRecord } from "../playwright/utils/kube-client/helpers"; + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new TypeError(`${label}: expected object`); + } + return value; +} + +describe("cloudsql-config", () => { + // Synthetic connection name for unit assertions only — runtime uses CLOUDSQL_INSTANCE_*. + const instanceConnectionName = "test-project:test-region:test-instance"; + + it("builds Auth Proxy as a native sidecar initContainer with startupProbe", () => { + const { container, volume } = buildCloudSqlProxySidecar(instanceConnectionName); + expect(container.name).toBe(CLOUD_SQL_PROXY_CONTAINER); + expect(container.image).toBe(CLOUD_SQL_PROXY_IMAGE); + expect(container.restartPolicy).toBe("Always"); + expect(container.args).toEqual([ + "--structured-logs", + "--credentials-file=/secrets/service_account.json", + instanceConnectionName, + ]); + expect(isRecord(container.startupProbe)).toBe(true); + expect(volume).toEqual(buildCloudSqlProxyVolume()); + expect(volume).toEqual({ + name: CLOUD_SQL_VOLUME, + secret: { secretName: CLOUD_SQL_SA_SECRET }, + }); + }); + + it("generates Helm overlay that disables local Postgres without replacing initContainers", () => { + const overlay = requireRecord(parseYaml(generateCloudSqlHelmValuesOverlay()), "overlay"); + const upstream = requireRecord(overlay.upstream, "upstream"); + const backstage = requireRecord(upstream.backstage, "backstage"); + const postgresql = requireRecord(upstream.postgresql, "postgresql"); + const appConfig = requireRecord(backstage.appConfig, "appConfig"); + const backend = requireRecord(appConfig.backend, "backend"); + const database = requireRecord(backend.database, "database"); + const connection = requireRecord(database.connection, "connection"); + + expect(postgresql.enabled).toBe(false); + expect(backstage.extraEnvVarsSecrets).toEqual(expect.arrayContaining(["postgres-cred"])); + // Proxy is patched onto the Deployment as initContainer — do not override chart initContainers. + expect(backstage.initContainers).toBeUndefined(); + expect(backstage.extraContainers).toBeUndefined(); + + if (!Array.isArray(backstage.extraEnvVars)) { + throw new TypeError("Cloud SQL Helm overlay missing extraEnvVars"); + } + const envNames = backstage.extraEnvVars.map((envVar: unknown) => + isRecord(envVar) ? envVar.name : undefined, + ); + expect(envNames).toContain("BACKEND_SECRET"); + expect(envNames).not.toContain("POSTGRESQL_ADMIN_PASSWORD"); + + expect(connection).toEqual({ + host: "${POSTGRES_HOST}", + port: "${POSTGRES_PORT}", + user: "${POSTGRES_USER}", + password: "${POSTGRES_PASSWORD}", + }); + + if (!Array.isArray(backstage.extraVolumes)) { + throw new TypeError("Cloud SQL Helm overlay missing extraVolumes"); + } + expect( + backstage.extraVolumes.some( + (volume: unknown) => isRecord(volume) && volume.name === CLOUD_SQL_VOLUME, + ), + ).toBe(true); + }); +}); diff --git a/e2e-tests/unit/config-liveness.test.ts b/e2e-tests/unit/config-liveness.test.ts new file mode 100644 index 0000000000..4b02bcfa65 --- /dev/null +++ b/e2e-tests/unit/config-liveness.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { appConfigMatchesExpected } from "../playwright/utils/authentication-providers/rhdh-deployment/config-liveness"; +import { + buildDeploymentLabelSelector, + labelSelectorFromMatchLabels, +} from "../playwright/utils/authentication-providers/rhdh-deployment/deployment-labels"; + +describe("deployment-labels", () => { + it("builds the standard backstage Deployment label selector", () => { + expect(buildDeploymentLabelSelector("rhdh")).toBe( + "app.kubernetes.io/name=backstage,app.kubernetes.io/instance=rhdh", + ); + }); + + it("joins matchLabels into a selector", () => { + expect(labelSelectorFromMatchLabels({ a: "1", b: "2" })).toBe("a=1,b=2"); + }); +}); + +describe("appConfigMatchesExpected", () => { + it("returns true when remote YAML matches expected config", () => { + const expected = { + auth: { + providers: { + github: { + production: { + sessionDuration: "3days", + disableIdentityResolution: true, + }, + }, + }, + autologout: { enabled: true }, + }, + }; + + expect( + appConfigMatchesExpected( + ` +auth: + providers: + github: + production: + sessionDuration: 3days + disableIdentityResolution: true + autologout: + enabled: true +`, + expected, + ), + ).toBe(true); + }); + + it("returns false when a critical auth key differs", () => { + expect( + appConfigMatchesExpected( + ` +auth: + providers: + github: + production: + sessionDuration: 3days +`, + { + auth: { + providers: { + github: { + production: { + sessionDuration: "1day", + }, + }, + }, + }, + }, + ), + ).toBe(false); + }); +}); diff --git a/e2e-tests/unit/deployment-readiness.test.ts b/e2e-tests/unit/deployment-readiness.test.ts new file mode 100644 index 0000000000..c4ba8c8b5c --- /dev/null +++ b/e2e-tests/unit/deployment-readiness.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import { waitForDeploymentReadiness } from "../playwright/utils/deployment-readiness"; + +describe("waitForDeploymentReadiness", () => { + it("runs created → HTTP → synced in order when all stages are requested", async () => { + const order: string[] = []; + await waitForDeploymentReadiness(["created", "http", "synced"], { + waitForCreated: () => { + order.push("created"); + return Promise.resolve(); + }, + waitForHttpReady: () => { + order.push("http"); + return Promise.resolve(); + }, + waitForSynced: () => { + order.push("synced"); + return Promise.resolve(); + }, + }); + + expect(order).toEqual(["created", "http", "synced"]); + }); + + it("skips stages that were not requested", async () => { + const waitForSynced = vi.fn<() => Promise>().mockResolvedValue(); + await waitForDeploymentReadiness(["created", "http"], { + waitForCreated: () => Promise.resolve(), + waitForHttpReady: () => Promise.resolve(), + waitForSynced, + }); + + expect(waitForSynced).not.toHaveBeenCalled(); + }); + + it("always respects stage order even if callers pass stages out of order", async () => { + const order: string[] = []; + await waitForDeploymentReadiness(["synced", "created"], { + waitForCreated: () => { + order.push("created"); + return Promise.resolve(); + }, + waitForHttpReady: () => { + order.push("http"); + return Promise.resolve(); + }, + waitForSynced: () => { + order.push("synced"); + return Promise.resolve(); + }, + }); + + expect(order).toEqual(["created", "synced"]); + }); +}); diff --git a/e2e-tests/unit/dynamic-plugins-profile.test.ts b/e2e-tests/unit/dynamic-plugins-profile.test.ts new file mode 100644 index 0000000000..401a4157b3 --- /dev/null +++ b/e2e-tests/unit/dynamic-plugins-profile.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + applyDynamicPluginsProfile, + createRuntimeDynamicPluginsProfile, + setPluginEnabled, +} from "../playwright/utils/dynamic-plugins-profile"; + +describe("DynamicPluginsProfile", () => { + it("forces includes to an empty list so operator defaults cannot return", () => { + const config = applyDynamicPluginsProfile({ + includes: ["dynamic-plugins.default.yaml"], + plugins: [{ package: "pkg-a", enabled: false }], + }); + + expect(config.includes).toEqual([]); + }); + + it("enables a plugin via enabled and clears conflicting disabled", () => { + const config = { + includes: [] as string[], + plugins: [{ package: "pkg-a", enabled: false, disabled: true }], + }; + + setPluginEnabled(config, "pkg-a", true); + + expect(config.plugins[0]).toMatchObject({ package: "pkg-a", enabled: true }); + expect(config.plugins[0].disabled).toBeUndefined(); + }); + + it("adds a missing plugin when enabling", () => { + const config = { includes: [] as string[], plugins: [] as Array<{ package: string }> }; + + setPluginEnabled(config, "pkg-new", true); + + expect(config.plugins).toEqual([{ package: "pkg-new", enabled: true }]); + }); + + it("builds the runtime homepage profile with includes empty", () => { + const profile = createRuntimeDynamicPluginsProfile(); + + expect(profile.includes).toEqual([]); + expect(profile.plugins[0]?.package).toContain("dynamic-home-page"); + expect(profile.plugins[0]?.enabled).toBe(true); + }); +}); diff --git a/e2e-tests/unit/instance-readiness.test.ts b/e2e-tests/unit/instance-readiness.test.ts new file mode 100644 index 0000000000..85a24f527e --- /dev/null +++ b/e2e-tests/unit/instance-readiness.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + classifyBaseUrlMode, + ensurePlaywrightReady, + shouldAutoDeployRuntime, +} from "../playwright/utils/instance-readiness"; + +type RequestContextOptions = { + baseURL: string; + ignoreHTTPSErrors: boolean; +}; + +type MockDispose = ReturnType Promise>>; + +type MockRequestContext = { + dispose: MockDispose; +}; + +function mockRequestContext(): { context: MockRequestContext; dispose: MockDispose } { + const dispose = vi.fn<() => Promise>().mockResolvedValue(); + return { + dispose, + context: { dispose }, + }; +} + +describe("classifyBaseUrlMode", () => { + it("returns unset when BASE_URL is missing", () => { + expect(classifyBaseUrlMode({})).toBe("unset"); + }); + + it("returns unset when BASE_URL is blank", () => { + expect(classifyBaseUrlMode({ BASE_URL: " " })).toBe("unset"); + }); + + it("returns router-stub when BASE_URL points only at the cluster router", () => { + expect( + classifyBaseUrlMode({ + BASE_URL: "https://apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + }), + ).toBe("router-stub"); + }); + + it("returns router-stub for apps.* host without K8S_CLUSTER_ROUTER_BASE", () => { + expect( + classifyBaseUrlMode({ + BASE_URL: "https://apps.cluster.example.com", + }), + ).toBe("router-stub"); + }); + + it("returns instance-url when BASE_URL points at an RHDH route", () => { + expect( + classifyBaseUrlMode({ + BASE_URL: "https://showcase-developer-hub-showcase-runtime.apps.cluster.example.com", + }), + ).toBe("instance-url"); + }); +}); + +describe("shouldAutoDeployRuntime", () => { + it("is false when the flag is unset", () => { + expect( + shouldAutoDeployRuntime({ + BASE_URL: "https://rhdh-developer-hub-showcase-runtime.apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + }), + ).toBe(false); + }); + + it("is true for unset or router-stub BASE_URL when the flag is set", () => { + expect(shouldAutoDeployRuntime({ RUNTIME_AUTO_DEPLOY: "true" })).toBe(true); + expect( + shouldAutoDeployRuntime({ + BASE_URL: "https://apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + }), + ).toBe(true); + }); + + it("is true only when BASE_URL matches the predicted runtime URL", () => { + expect( + shouldAutoDeployRuntime( + { + BASE_URL: "https://rhdh-developer-hub-showcase-runtime.apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + RELEASE_NAME: "rhdh", + NAME_SPACE_RUNTIME: "showcase-runtime", + }, + "helm", + ), + ).toBe(true); + expect( + shouldAutoDeployRuntime( + { + BASE_URL: "https://rhdh-developer-hub-showcase-sanity.apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + RELEASE_NAME: "rhdh", + NAME_SPACE_RUNTIME: "showcase-runtime", + }, + "helm", + ), + ).toBe(false); + }); +}); + +describe("ensurePlaywrightReady", () => { + it("deploys then waits when auto-deploy is enabled with a predicted instance URL", async () => { + const predicted = "https://showcase-developer-hub-showcase-runtime.apps.cluster.example.com"; + const env: Record = { + BASE_URL: predicted, + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + RELEASE_NAME: "showcase", + NAME_SPACE_RUNTIME: "showcase-runtime", + }; + const { context: requestContext, dispose } = mockRequestContext(); + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockResolvedValue(); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockImplementation((options) => { + expect(options).toEqual({ + baseURL: predicted, + ignoreHTTPSErrors: true, + }); + return Promise.resolve(requestContext); + }); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + resolveInstallMethod: () => "helm", + }); + + expect(ensureRuntimeDeployed).toHaveBeenCalledOnce(); + expect(createRequestContext).toHaveBeenCalledOnce(); + expect(waitForRhdhReady).toHaveBeenCalledOnce(); + expect(waitForRhdhReady).toHaveBeenCalledWith(requestContext); + expect(dispose).toHaveBeenCalledOnce(); + expect(ensureRuntimeDeployed.mock.invocationCallOrder[0]).toBeLessThan( + waitForRhdhReady.mock.invocationCallOrder[0], + ); + }); + + it("deploys then waits when BASE_URL is unset and auto-deploy is enabled", async () => { + const env: Record = { + RUNTIME_AUTO_DEPLOY: "true", + }; + const { context: requestContext } = mockRequestContext(); + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockImplementation(() => { + env.BASE_URL = "https://showcase-developer-hub-showcase-runtime.apps.cluster.example.com"; + return Promise.resolve(); + }); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(requestContext); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + }); + + expect(ensureRuntimeDeployed).toHaveBeenCalledOnce(); + expect(waitForRhdhReady).toHaveBeenCalledOnce(); + }); + + it("throws when auto-deploy finishes without an instance BASE_URL", async () => { + await expect( + ensurePlaywrightReady({ + env: { RUNTIME_AUTO_DEPLOY: "true" }, + ensureRuntimeDeployed: vi.fn<() => Promise>().mockResolvedValue(), + createRequestContext: + vi.fn<(options: RequestContextOptions) => Promise>(), + waitForRhdhReady: vi.fn<(request: MockRequestContext) => Promise>(), + }), + ).rejects.toThrow("Runtime auto-deploy did not produce an instance BASE_URL"); + }); + + it("does nothing when BASE_URL is unset and auto-deploy is disabled", async () => { + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockResolvedValue(); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(mockRequestContext().context); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env: {}, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + }); + + expect(ensureRuntimeDeployed).not.toHaveBeenCalled(); + expect(createRequestContext).not.toHaveBeenCalled(); + expect(waitForRhdhReady).not.toHaveBeenCalled(); + }); + + it("does nothing when BASE_URL is only the cluster router without auto-deploy", async () => { + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockResolvedValue(); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(mockRequestContext().context); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env: { + BASE_URL: "https://apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + }, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + }); + + expect(ensureRuntimeDeployed).not.toHaveBeenCalled(); + expect(createRequestContext).not.toHaveBeenCalled(); + expect(waitForRhdhReady).not.toHaveBeenCalled(); + }); + + it("deploys when router-stub BASE_URL is paired with auto-deploy", async () => { + const env: Record = { + BASE_URL: "https://apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + }; + const { context: requestContext } = mockRequestContext(); + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockImplementation(() => { + env.BASE_URL = "https://showcase-developer-hub-showcase-runtime.apps.cluster.example.com"; + return Promise.resolve(); + }); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(requestContext); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + }); + + expect(ensureRuntimeDeployed).toHaveBeenCalledOnce(); + expect(waitForRhdhReady).toHaveBeenCalledOnce(); + }); + + it("throws when auto-deploy leaves BASE_URL as a router-stub", async () => { + await expect( + ensurePlaywrightReady({ + env: { + BASE_URL: "https://apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + }, + ensureRuntimeDeployed: vi.fn<() => Promise>().mockResolvedValue(), + createRequestContext: + vi.fn<(options: RequestContextOptions) => Promise>(), + waitForRhdhReady: vi.fn<(request: MockRequestContext) => Promise>(), + }), + ).rejects.toThrow("Runtime auto-deploy did not produce an instance BASE_URL"); + }); + + it("ignores a leaked RUNTIME_AUTO_DEPLOY flag for non-runtime instance URLs", async () => { + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockResolvedValue(); + const { context: requestContext } = mockRequestContext(); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(requestContext); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env: { + BASE_URL: "https://rhdh-developer-hub-showcase-sanity.apps.cluster.example.com", + K8S_CLUSTER_ROUTER_BASE: "apps.cluster.example.com", + RUNTIME_AUTO_DEPLOY: "true", + RELEASE_NAME: "rhdh", + NAME_SPACE_RUNTIME: "showcase-runtime", + }, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + resolveInstallMethod: () => "helm", + }); + + expect(ensureRuntimeDeployed).not.toHaveBeenCalled(); + expect(waitForRhdhReady).toHaveBeenCalledOnce(); + }); + + it("waits only when BASE_URL points at a deployed instance", async () => { + const { context: requestContext, dispose } = mockRequestContext(); + const ensureRuntimeDeployed = vi.fn<() => Promise>().mockResolvedValue(); + const createRequestContext = vi + .fn<(options: RequestContextOptions) => Promise>() + .mockResolvedValue(requestContext); + const waitForRhdhReady = vi + .fn<(request: MockRequestContext) => Promise>() + .mockResolvedValue(); + + await ensurePlaywrightReady({ + env: { + BASE_URL: "https://backstage-showcase-showcase-runtime.apps.cluster.example.com", + }, + ensureRuntimeDeployed, + createRequestContext, + waitForRhdhReady, + }); + + expect(ensureRuntimeDeployed).not.toHaveBeenCalled(); + expect(createRequestContext).toHaveBeenCalledOnce(); + expect(createRequestContext).toHaveBeenCalledWith({ + baseURL: "https://backstage-showcase-showcase-runtime.apps.cluster.example.com", + ignoreHTTPSErrors: true, + }); + expect(waitForRhdhReady).toHaveBeenCalledOnce(); + expect(waitForRhdhReady).toHaveBeenCalledWith(requestContext); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/e2e-tests/unit/instance-route-identity.test.ts b/e2e-tests/unit/instance-route-identity.test.ts new file mode 100644 index 0000000000..a9cf71116b --- /dev/null +++ b/e2e-tests/unit/instance-route-identity.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + deploymentName, + isPredictedRuntimeUrl, + predictedUrl, + routeObjectName, + runtimeInstanceRouteIdentity, +} from "../playwright/utils/instance-route-identity"; + +describe("instance-route-identity", () => { + it("names helm routes and deployments with the developer-hub suffix", () => { + expect(routeObjectName("helm", "rhdh")).toBe("rhdh-developer-hub"); + expect(deploymentName("helm", "rhdh")).toBe("rhdh-developer-hub"); + expect( + predictedUrl({ + installMethod: "helm", + releaseName: "rhdh", + namespace: "showcase-runtime", + routerBase: "apps.cluster.example.com", + }), + ).toBe("https://rhdh-developer-hub-showcase-runtime.apps.cluster.example.com"); + }); + + it("names operator routes and deployments with the backstage- prefix", () => { + expect(routeObjectName("operator", "rhdh")).toBe("backstage-rhdh"); + expect(deploymentName("operator", "rhdh")).toBe("backstage-rhdh"); + expect( + predictedUrl({ + installMethod: "operator", + releaseName: "rhdh", + namespace: "showcase-runtime", + routerBase: "apps.cluster.example.com", + }), + ).toBe("https://backstage-rhdh-showcase-runtime.apps.cluster.example.com"); + }); + + it("keeps an already-suffixed helm release name", () => { + expect(routeObjectName("helm", "my-developer-hub")).toBe("my-developer-hub"); + }); + + it("builds runtime identity from env defaults", () => { + expect( + runtimeInstanceRouteIdentity("helm", "apps.example.com", { + RELEASE_NAME: "showcase", + NAME_SPACE_RUNTIME: "showcase-runtime", + }), + ).toEqual({ + installMethod: "helm", + releaseName: "showcase", + namespace: "showcase-runtime", + routerBase: "apps.example.com", + }); + }); + + it("matches only the predicted runtime URL for auto-deploy gating", () => { + const env = { + RELEASE_NAME: "rhdh", + NAME_SPACE_RUNTIME: "showcase-runtime", + }; + expect( + isPredictedRuntimeUrl( + "https://rhdh-developer-hub-showcase-runtime.apps.cluster.example.com", + "helm", + "apps.cluster.example.com", + env, + ), + ).toBe(true); + expect( + isPredictedRuntimeUrl( + "https://rhdh-developer-hub-showcase-sanity.apps.cluster.example.com", + "helm", + "apps.cluster.example.com", + env, + ), + ).toBe(false); + expect( + isPredictedRuntimeUrl( + "https://backstage-rhdh-showcase-runtime.apps.cluster.example.com", + "helm", + "apps.cluster.example.com", + env, + ), + ).toBe(false); + }); +}); diff --git a/e2e-tests/unit/operator-install-profile.test.ts b/e2e-tests/unit/operator-install-profile.test.ts new file mode 100644 index 0000000000..0f7719586a --- /dev/null +++ b/e2e-tests/unit/operator-install-profile.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + BACKSTAGE_CR_API_VERSION, + OPERATOR_BACKEND_SECRET, + applyOperatorInstallProfileToAppConfig, + applyOperatorInstallProfileToCr, + type BackstageCrLike, + type YamlRecord, +} from "../playwright/utils/operator-install-profile"; + +describe("OperatorInstallProfile", () => { + it("pins the Backstage CR API version and empty flavours", () => { + const cr: BackstageCrLike = { + apiVersion: "rhdh.redhat.com/v1alpha4", + kind: "Backstage", + metadata: { name: "rhdh" }, + spec: { application: { extraEnvs: { envs: [] } } }, + }; + + applyOperatorInstallProfileToCr(cr); + + expect(cr.apiVersion).toBe(BACKSTAGE_CR_API_VERSION); + expect(cr.spec.flavours).toEqual([]); + }); + + it("injects BACKEND_SECRET into auth-provider CR envs", () => { + const cr: BackstageCrLike = { + apiVersion: "rhdh.redhat.com/v1alpha4", + kind: "Backstage", + metadata: { name: "rhdh" }, + spec: { + application: { + extraEnvs: { + envs: [{ name: "NODE_OPTIONS", value: "--no-node-snapshot" }], + }, + }, + }, + }; + + applyOperatorInstallProfileToCr(cr); + + expect(JSON.stringify(cr)).toContain(`"name":"BACKEND_SECRET"`); + expect(JSON.stringify(cr)).toContain(`"value":"${OPERATOR_BACKEND_SECRET}"`); + }); + + it("rewrites auth app-config keys to BACKEND_SECRET and drops sqlite for operator", () => { + const appConfig: YamlRecord = { + backend: { + auth: { keys: [{ secret: "temp" }] }, + database: { client: "better-sqlite3", connection: ":memory:" }, + }, + }; + + applyOperatorInstallProfileToAppConfig(appConfig, "auth-providers"); + + expect(JSON.stringify(appConfig)).toContain('{"secret":"${BACKEND_SECRET}"}'); + expect(JSON.stringify(appConfig)).not.toContain("better-sqlite3"); + }); +}); diff --git a/e2e-tests/unit/popup-login-outcome.test.ts b/e2e-tests/unit/popup-login-outcome.test.ts new file mode 100644 index 0000000000..b75fe5024e --- /dev/null +++ b/e2e-tests/unit/popup-login-outcome.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { isPopupLoginSuccess } from "../playwright/support/auth/app-shell"; + +describe("isPopupLoginSuccess", () => { + it("treats Login successful and Already logged in as success", () => { + expect(isPopupLoginSuccess("Login successful")).toBe(true); + expect(isPopupLoginSuccess("Already logged in")).toBe(true); + }); + + it("treats IdP rejection statuses as not success", () => { + expect(isPopupLoginSuccess("User does not exist")).toBe(false); + expect(isPopupLoginSuccess("Login failed - invalid credentials")).toBe(false); + }); +}); diff --git a/e2e-tests/unit/runtime-config.test.ts b/e2e-tests/unit/runtime-config.test.ts new file mode 100644 index 0000000000..1848fecf86 --- /dev/null +++ b/e2e-tests/unit/runtime-config.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { buildImageRef } from "../playwright/utils/helper"; +import { + generateAppConfigYaml, + generateBackstageCR, + generateDynamicPluginsYaml, +} from "../playwright/utils/runtime-config"; + +describe("operator runtime config", () => { + it("wires BACKEND_SECRET into app-config keys and externalAccess", () => { + const yamlText = generateAppConfigYaml("https://example.test"); + expect(yamlText).toContain("secret: ${BACKEND_SECRET}"); + expect(yamlText).toMatch(/keys:[\s\S]*secret: \$\{BACKEND_SECRET\}/u); + }); + + it("enables the dynamic homepage plugin", () => { + const yamlText = generateDynamicPluginsYaml(); + expect(yamlText).toContain("red-hat-developer-hub-backstage-plugin-dynamic-home-page"); + expect(yamlText).toContain("DynamicHomePage"); + expect(yamlText).not.toMatch(/plugins:\s*\[\]/u); + expect(yamlText).toContain("includes: []"); + }); + + it("injects BACKEND_SECRET into the Backstage CR env", () => { + const cr = generateBackstageCR({ + releaseName: "rhdh", + namespace: "showcase-runtime", + routerBase: "apps.example.com", + image: buildImageRef("quay.io", "rhdh/rhdh-hub-rhel9", "next"), + }); + const serialized = JSON.stringify(cr); + expect(serialized).toContain('"name":"BACKEND_SECRET"'); + expect(serialized).toContain('"value":"super-secret-for-tests"'); + expect(cr.spec.flavours).toEqual([]); + expect(cr.apiVersion).toBe("rhdh.redhat.com/v1alpha5"); + }); +}); diff --git a/e2e-tests/unit/wait-for-rhdh-ready.test.ts b/e2e-tests/unit/wait-for-rhdh-ready.test.ts new file mode 100644 index 0000000000..c8028e6988 --- /dev/null +++ b/e2e-tests/unit/wait-for-rhdh-ready.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isJsonHealthcheckResponse, + probeHealthcheck, + type HealthcheckHttpClient, +} from "../playwright/utils/wait-for-rhdh-ready"; + +describe("isJsonHealthcheckResponse", () => { + it("accepts 200 JSON health responses", () => { + expect(isJsonHealthcheckResponse(200, "application/json")).toBe(true); + }); + + it("rejects 503 so callers can surface backend-not-ready", () => { + expect(isJsonHealthcheckResponse(503, "application/json")).toBe(false); + }); + + it("rejects non-JSON 200 responses", () => { + expect(isJsonHealthcheckResponse(200, "text/html")).toBe(false); + }); +}); + +describe("probeHealthcheck", () => { + it("treats transport timeouts as not-ready so expect.poll can keep retrying", async () => { + const request: HealthcheckHttpClient = { + get: vi + .fn() + .mockRejectedValue(new Error("Timeout 10000ms exceeded")), + }; + + const probe = await probeHealthcheck(request); + + expect(probe.ok).toBe(false); + expect(probe.detail).toBe("request failed: Timeout 10000ms exceeded"); + }); + + it("reports status ok for a healthy JSON /healthcheck response", async () => { + const request: HealthcheckHttpClient = { + get: vi.fn().mockResolvedValue({ + status: () => 200, + headers: () => ({ "content-type": "application/json" }), + json: () => Promise.resolve({ status: "ok" }), + }), + }; + + await expect(probeHealthcheck(request)).resolves.toEqual({ + ok: true, + detail: "status ok", + }); + }); +}); diff --git a/e2e-tests/yarn.lock b/e2e-tests/yarn.lock index f8a7a695a1..ca51eb8ac4 100644 --- a/e2e-tests/yarn.lock +++ b/e2e-tests/yarn.lock @@ -877,6 +877,57 @@ __metadata: languageName: node linkType: hard +"@reteps/dockerfmt-darwin-arm64@npm:0.5.4": + version: 0.5.4 + resolution: "@reteps/dockerfmt-darwin-arm64@npm:0.5.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@reteps/dockerfmt-darwin-x64@npm:0.5.4": + version: 0.5.4 + resolution: "@reteps/dockerfmt-darwin-x64@npm:0.5.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@reteps/dockerfmt-linux-arm64@npm:0.5.4": + version: 0.5.4 + resolution: "@reteps/dockerfmt-linux-arm64@npm:0.5.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@reteps/dockerfmt-linux-x64@npm:0.5.4": + version: 0.5.4 + resolution: "@reteps/dockerfmt-linux-x64@npm:0.5.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@reteps/dockerfmt@npm:^0.5.1": + version: 0.5.4 + resolution: "@reteps/dockerfmt@npm:0.5.4" + dependencies: + "@reteps/dockerfmt-darwin-arm64": "npm:0.5.4" + "@reteps/dockerfmt-darwin-x64": "npm:0.5.4" + "@reteps/dockerfmt-linux-arm64": "npm:0.5.4" + "@reteps/dockerfmt-linux-x64": "npm:0.5.4" + dependenciesMeta: + "@reteps/dockerfmt-darwin-arm64": + optional: true + "@reteps/dockerfmt-darwin-x64": + optional: true + "@reteps/dockerfmt-linux-arm64": + optional: true + "@reteps/dockerfmt-linux-x64": + optional: true + bin: + dockerfmt: dist/launcher.js + checksum: 10c0/b6f3037042d258bd3b84d53f879cffb0286fe245f41da54508870866b9bf2c97bb1a2e15c6f29a610ed70ce43ffb1d6e7281002325cd9b01a08aeb3f391ba12a + languageName: node + linkType: hard + "@rolldown/binding-android-arm64@npm:1.1.3": version: 1.1.3 resolution: "@rolldown/binding-android-arm64@npm:1.1.3" @@ -2003,6 +2054,8 @@ __metadata: oxlint: "npm:1.71.0" oxlint-tsgolint: "npm:0.23.0" pg: "npm:8.22.0" + prettier: "npm:3.8.4" + prettier-plugin-sh: "npm:0.18.1" shellcheck: "npm:4.1.0" typescript: "npm:6.0.3" uuid: "npm:14.0.1" @@ -4081,6 +4134,27 @@ __metadata: languageName: node linkType: hard +"prettier-plugin-sh@npm:0.18.1": + version: 0.18.1 + resolution: "prettier-plugin-sh@npm:0.18.1" + dependencies: + "@reteps/dockerfmt": "npm:^0.5.1" + sh-syntax: "npm:^0.5.8" + peerDependencies: + prettier: ^3.6.0 + checksum: 10c0/e79a8dab1b9cd966bf078b1ca4ec412d190fb905ca6bfb89f841d309ed39ecb7136901b64919e3d8e52e9233d658b64bc69203ff81384914ec90dedbcbe804da + languageName: node + linkType: hard + +"prettier@npm:3.8.4": + version: 3.8.4 + resolution: "prettier@npm:3.8.4" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/b90a0cbe75b88ac0af9c13fe0f359bd19926fabccd88483227b21f71f0c1cc42da056fc1ac3a361e665577c568371d5ccfb2c62c31c8a1186f8d1bd531a063e9 + languageName: node + linkType: hard + "proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": version: 4.2.0 resolution: "proc-log@npm:4.2.0" @@ -4402,6 +4476,15 @@ __metadata: languageName: node linkType: hard +"sh-syntax@npm:^0.5.8": + version: 0.5.8 + resolution: "sh-syntax@npm:0.5.8" + dependencies: + tslib: "npm:^2.8.1" + checksum: 10c0/2d2609fc8760ef97175c852be26ee3eeb196078c5aec282c8b96a59ee362be4f470d3e0df4e372da6c7a7b44ccc42910cbdcb0915271b3cb6a6212d00dde116f + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" diff --git a/translations/test/all-en.json b/translations/test/all-en.json index 6bcc3e1e2f..a666f302b8 100644 --- a/translations/test/all-en.json +++ b/translations/test/all-en.json @@ -24,7 +24,8 @@ "pinToggle.description": "Prevent the sidebar from collapsing", "pinToggle.ariaLabelTitle": "Pin Sidebar Switch", "providerSettingsItem.buttonTitle.signIn": "Sign in", - "providerSettingsItem.title.signIn": "Sign in to {{title}}" + "providerSettingsItem.title.signIn": "Sign in to {{title}}", + "providerSettingsItem.title.signOut": "Sign out from {{title}}" } }, "catalog": {