Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/test-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,39 @@ jobs:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
make test-e2e E2E_IMAGE_SOURCE=$E2E_IMAGE_SOURCE TAG=$IMAGE_TAG REGISTRY=$REGISTRY

test-ui-e2e:
runs-on: ubuntu-24.04
defaults:
run:
shell: nix develop --command bash -e {0}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install nix
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
with:
determinate: false
diagnostic-endpoint: ''
- name: Use opendefensecloud Cachix cache
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: opendefensecloud
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
signingKey: ${{ secrets.CACHIX_SIGNING_KEY }}
- name: Run UI E2E Test Suite
env:
E2E_IMAGE_SOURCE: ${{ inputs.is-local && 'local' || 'ghcr' }}
REGISTRY: ${{ inputs.is-local && 'localhost:5001/local' || format('ghcr.io/{0}', github.repository_owner) }}
IMAGE_TAG: ${{ inputs.image-tag }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
make ui-test-e2e E2E_IMAGE_SOURCE=$E2E_IMAGE_SOURCE TAG=$IMAGE_TAG REGISTRY=$REGISTRY
Comment thread
rebEllieous marked this conversation as resolved.
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: web/playwright-report/
retention-days: 7
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,11 @@ ui-dev: ui-install ## Start Go backend + Vite dev server against the UI dev clus
ui-e2e-cluster: ocm-transfer-demo ## Create a Kind cluster with Dex + SolAr for UI e2e testing
WORK_DIR=$(UI_E2E_WORK_DIR) $(HACK_DIR)/generate-dex-certs.sh
KIND_CONFIG=$(UI_E2E_WORK_DIR)/kind-config-oidc.yaml $(MAKE) setup-local-cluster KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E)
$(MAKE) docker-build-local-images TAG=e2e
$(MAKE) kind-load-local-images TAG=e2e KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E)
TAG=e2e KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E) $(HACK_DIR)/dev-cluster.sh
@if [ "$(E2E_IMAGE_SOURCE)" = "local" ]; then \
$(MAKE) docker-build-local-images TAG=$(TAG) REGISTRY=$(REGISTRY); \
$(MAKE) kind-load-local-images TAG=$(TAG) KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E) REGISTRY=$(REGISTRY); \
fi
REGISTRY=$(REGISTRY) TAG=$(TAG) KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E) $(HACK_DIR)/dev-cluster.sh
KIND_CLUSTER=$(KIND_CLUSTER_UI_E2E) $(HACK_DIR)/setup-dex.sh

.PHONY: ui-cleanup-e2e-cluster
Expand Down
48 changes: 26 additions & 22 deletions docs/developer-guide/frontend-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ If you use the provided Nix flake (`nix develop` or `direnv`), all of these are

## The three commands

| Command | When to run | What it does |
| ------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `make ui-dev-cluster` | Once, or after `make ui-cleanup-dev-cluster` | Creates the `solar-ui-dev` Kind cluster, builds and loads dev images, installs SolAr, sets up Dex for OIDC. |
| `make ui-seed-data` | Once after creating the cluster | Seeds demo `Target`, `Release`, `Component`, etc. resources so the UI has something to render. |
| `make ui-dev` | Every dev session | Starts Dex port-forward + Vite dev server (`:5173`) + `solar-ui` BFF (`:8090`), wired together. |
| Command | When to run | What it does |
| --------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `make ui-dev-cluster` | Once, or after `make ui-cleanup-dev-cluster` | Creates the `solar-ui-dev` Kind cluster, builds and loads dev images, installs SolAr, sets up Dex for OIDC. |
| `make ui-seed-data` | Once after creating the cluster | Seeds demo `Target`, `Release`, `Component`, etc. resources so the UI has something to render. |
| `make ui-dev` | Every dev session | Starts Dex port-forward + Vite dev server (`:5173`) + `solar-ui` BFF (`:8090`), wired together. |

### Typical first-time flow

Expand All @@ -68,12 +68,12 @@ make ui-dev # auto-creates the cluster if missing, otherwise just star

The UI uses OIDC against the in-cluster Dex. After opening `http://localhost:8090`, click through the Dex login. Static demo users are configured in `test/fixtures/e2e/dex/dex-config.yaml` and mirror the personas in [Roles](./roles.md):

| Dex login | OIDC email (K8s identity) | Persona | Sees |
| --------- | ------------------------- | ------- | ---- |
| `admin` | `admin@solar.local` | Solar Operator (admin) | everything, plus can use "Preview as" |
| `acm` | `app-catalog-maintainer@solar.local` | App Catalog Maintainer | Components, ComponentVersions in `app-catalog-maintainer` |
| `kcp` | `k8s-cluster-provider@solar.local` | K8s Cluster Provider | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-provider`; Targets in `k8s-cluster-user`; read-only catalog |
| `kcu` | `k8s-cluster-user@solar.local` | K8s Cluster User | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-user`; read/update Targets; read-only catalog |
| Dex login | OIDC email (K8s identity) | Persona | Sees |
| --------- | ------------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `admin` | `admin@solar.local` | Solar Operator (admin) | everything, plus can use "Preview as" |
| `acm` | `app-catalog-maintainer@solar.local` | App Catalog Maintainer | Components, ComponentVersions in `app-catalog-maintainer` |
| `kcp` | `k8s-cluster-provider@solar.local` | K8s Cluster Provider | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-provider`; Targets in `k8s-cluster-user`; read-only catalog |
| `kcu` | `k8s-cluster-user@solar.local` | K8s Cluster User | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-user`; read/update Targets; read-only catalog |

All passwords are the literal string `password`. Cluster RBAC bindings live in `test/fixtures/e2e/dex/dex-rbac.yaml` (inlined from `docs/developer-guide/manifests/`).

Expand All @@ -86,7 +86,7 @@ The sidebar's namespace dropdown is the global scope for every list page (Target

### How the dropdown is populated

The user's token is *not* used to enumerate cluster namespaces — most personas don't have that permission. Instead the BFF runs a **discovery proxy + per-user filter**:
The user's token is _not_ used to enumerate cluster namespaces — most personas don't have that permission. Instead the BFF runs a **discovery proxy + per-user filter**:

```text
GET /api/namespaces
Expand Down Expand Up @@ -124,24 +124,24 @@ Source: `pkg/ui/api/handler.go::HandleListNamespaces`.

The selector hides the "All namespaces" option when the **current** identity can't satisfy a cluster-scope `list namespaces` `SelfSubjectAccessReview`. The check is cached on the session and invalidated whenever impersonation changes, so previewing as a persona correctly removes the option until the admin restores their real identity.

| Identity | Can pick "All"? | Why |
| --- | --- | --- |
| `admin@solar.local` | yes | bound to `cluster-admin` |
| Any persona | no | persona RoleBindings are namespace-scoped |
| Admin previewing as persona | no | impersonated identity has no cluster-scope perm |
| Identity | Can pick "All"? | Why |
| --------------------------- | --------------- | ----------------------------------------------- |
| `admin@solar.local` | yes | bound to `cluster-admin` |
| Any persona | no | persona RoleBindings are namespace-scoped |
| Admin previewing as persona | no | impersonated identity has no cluster-scope perm |

If the persisted "All" choice becomes invalid (impersonation switch, RBAC change), the selector falls back to the first namespace the user can still see.

## Testing impersonation

Log in as `admin@solar.local`. The sidebar shows a "Preview as" form (only for admins — gated by a cluster-scope `impersonate users` check). Type one of the persona emails:

| Preview as | Expected views |
| ----------- | -------------- |
| Preview as | Expected views |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app-catalog-maintainer@solar.local` | Components/ComponentVersions visible only in the `app-catalog-maintainer` namespace. Targets/Releases/Profiles all 403 → the page shows the lock-screen explainer. Selector hides "All namespaces". |
| `k8s-cluster-provider@solar.local` | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-provider`; Targets in `k8s-cluster-user`; catalog read-only. |
| `k8s-cluster-user@solar.local` | Targets read/update in `k8s-cluster-user`; Releases / Profiles / Registries in `k8s-cluster-user`; catalog read-only. |
| Any string K8s doesn't recognise | Form succeeds (BFF accepts whatever you type), but every subsequent list 403s — that's RBAC working as designed. |
| `k8s-cluster-provider@solar.local` | Releases, Profiles, Registries, ReleaseBindings in `k8s-cluster-provider`; Targets in `k8s-cluster-user`; catalog read-only. |
| `k8s-cluster-user@solar.local` | Targets read/update in `k8s-cluster-user`; Releases / Profiles / Registries in `k8s-cluster-user`; catalog read-only. |
| Any string K8s doesn't recognise | Form succeeds (BFF accepts whatever you type), but every subsequent list 403s — that's RBAC working as designed. |

What happens under the hood on each switch:

Expand Down Expand Up @@ -206,4 +206,8 @@ cd web && pnpm test # Vitest unit tests
make ui-test-e2e # Playwright e2e (uses a separate `solar-test-e2e-ui` cluster)
```

In CI the Playwright suite runs as the `test-ui-e2e` job in `.github/workflows/test-e2e.yaml`, in parallel with the Go e2e job and gated by the same trigger (push to `main`, a release, or a PR labelled `ok-to-e2e` / `ok-to-image`). It reuses the images `docker.yaml` pushed to GHCR instead of building them, failures are annotated in the PR diff by Playwright's `github` reporter, and the HTML report is attached to the run as the `playwright-report` artifact.

The two e2e suites keep separate Kind clusters on purpose: `solar-test-e2e-ui` is created with an OIDC-enabled apiserver plus Dex and needs one stable SolAr install for the whole run, while the Go suite installs and uninstalls SolAr repeatedly. Sharing a cluster would serialise both suites and couple their failures.

See also: [UI Architecture ADR](./adrs/010-UI-Architecture.md).
42 changes: 35 additions & 7 deletions hack/dev-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ set -euo pipefail
KIND_CLUSTER="${KIND_CLUSTER:-solar-dev}"
SKIP_SOLAR="${SKIP_SOLAR:-false}"
TAG="${TAG:-latest}"
REGISTRY="${REGISTRY:-localhost/local}"
GHCR_TOKEN="${GHCR_TOKEN:-}"

FLUX="${FLUX:-flux}"
HELM="${HELM:-helm}"
Expand Down Expand Up @@ -190,20 +192,38 @@ setup_flux() {
--timeout 5m
}

# setup_solar installs the Solar Helm chart into the solar-system namespace and applies the Zot deployment authorization manifest, setting component image tags to the current TAG.
# create_pull_secret creates the ghcr-pull-secret docker-registry secret in the
# given namespace. No-op without a GHCR_TOKEN
create_pull_secret() {
local namespace="$1"
[[ -n "$GHCR_TOKEN" ]] || return 0
$KUBECTL create secret docker-registry ghcr-pull-secret \
--namespace "$namespace" \
--docker-server=ghcr.io \
--docker-username=x-access-token \
--docker-password="$GHCR_TOKEN" \
--dry-run=client -o yaml | $KUBECTL apply -f -
}

# setup_solar installs the Solar Helm chart into the solar-system namespace, setting component image repositories/tags to the current REGISTRY/TAG. The namespace and its prerequisites (trust label, Zot deploy auth secret) are set up by main beforehand.
setup_solar() {
echo -e "\nSETTING UP SOLAR:\n"
local pull_secret_args=()
if [[ -n "$GHCR_TOKEN" ]]; then
create_pull_secret solar-system
Comment thread
rebEllieous marked this conversation as resolved.
pull_secret_args=(--set 'global.imagePullSecrets[0].name=ghcr-pull-secret')
fi
$HELM upgrade --install \
--create-namespace \
--namespace=solar-system \
solar charts/solar \
-f test/fixtures/solar.values.yaml \
--set apiserver.image.repository="$REGISTRY/solar-apiserver" \
--set controller.image.repository="$REGISTRY/solar-controller-manager" \
--set renderer.image.repository="$REGISTRY/solar-renderer" \
--set apiserver.image.tag="$TAG" \
--set controller.image.tag="$TAG" \
--set renderer.image.tag="$TAG"
$KUBECTL apply --namespace=solar-system \
-f test/fixtures/e2e/zot-deploy-auth.yaml
$KUBECTL label namespace solar-system trust=enabled --overwrite
--set renderer.image.tag="$TAG" \
"${pull_secret_args[@]}"

# Wait for the aggregated apiserver to be ready before returning. Without
# this, callers (e.g. the UI e2e tests) can hit solar.opendefense.cloud
Expand Down Expand Up @@ -236,12 +256,18 @@ setup_discovery() {
echo -e "\nSETTING UP SOLAR-DISCOVERY:\n"
$KUBECTL apply --namespace=solar-system -f test/fixtures/e2e/zot-discovery-auth.yaml
$KUBECTL apply --namespace=solar-system -f test/fixtures/e2e/zot-discovery-registry-scan.yaml
local pull_secret_args=()
if [[ -n "$GHCR_TOKEN" ]]; then
pull_secret_args=(--set 'imagePullSecrets[0].name=ghcr-pull-secret')
fi
$HELM upgrade --install \
--namespace=solar-system \
solar-discovery charts/solar-discovery \
-f test/fixtures/solar-discovery-scan.values.yaml \
--set image.repository="$REGISTRY/solar-discovery" \
--set image.tag="$TAG" \
--set namespace=solar-system
--set namespace=solar-system \
"${pull_secret_args[@]}"
$KUBECTL wait deployment \
--namespace solar-system \
-l app.kubernetes.io/instance=solar-discovery \
Expand All @@ -262,6 +288,8 @@ main() {
if [[ "$SKIP_SOLAR" != "true" ]]; then
$KUBECTL create namespace solar-system 2>/dev/null || true
$KUBECTL label namespace solar-system trust=enabled --overwrite
$KUBECTL apply --namespace=solar-system \
-f test/fixtures/e2e/zot-deploy-auth.yaml
setup_solar
setup_discovery
fi
Expand Down
16 changes: 12 additions & 4 deletions web/e2e/pipeline-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ const RELEASE_BINDING = {
name: "production-cluster-1-binding",
namespace: NS,
creationTimestamp: NOW,
ownerReferences: [
{
apiVersion: "solar.opendefense.cloud/v1alpha1",
kind: "Profile",
name: PROFILE.metadata.name,
uid: "00000000-0000-0000-0000-0000000000p1",
controller: true,
},
],
},
spec: {
releaseRef: { name: RELEASE.metadata.name },
Expand Down Expand Up @@ -181,12 +190,11 @@ async function setupMocks(page: Page) {
if (p === `/api/namespaces/${ns}/rendertasks`)
return route.fulfill({ json: list([RENDER_TASK]) });

// All-namespace list routes → 403: the UI must pick a namespace before
// fetching, so these should never succeed in normal operation.
if (p === "/api/targets") return route.fulfill({ json: list([TARGET]) });

if (
p === "/api/profiles" ||
p === "/api/releases" ||
p === "/api/targets" ||
p === "/api/releasebindings" ||
p === "/api/rendertasks"
)
Expand Down Expand Up @@ -236,7 +244,7 @@ test.describe("Profile → Release → Target click journey", () => {
).toBeVisible();

// Target selector labels
await expect(page.getByText("env=prod")).toBeVisible();
await expect(page.getByText(/env\s*=\s*prod/)).toBeVisible();

// Matched target appears in the target list
await expect(
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@tailwindcss/vite": "^4.3.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.2.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
Expand Down
4 changes: 3 additions & 1 deletion web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export default defineConfig({
},
fullyParallel: false,
retries: 1,
reporter: "html",
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: "html",
use: {
baseURL: "http://localhost:8090",
trace: "on-first-retry",
Expand Down
Loading
Loading