From 89a075ea2107d39ab7182e19f386c2fcb9cf5423 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 17:24:56 +0300 Subject: [PATCH] Add integration connector plugins and compose fixtures Scaffold connector plugins for DockerRegistry, GitLab, Gitea, Jenkins, and Nexus. Wire plugin discovery in IntegrationService and add compose fixtures for local integration testing. - 5 new connector plugins under src/Integrations/__Plugins/ - docker-compose.integrations.yml for local fixture services - Advisory source catalog and source management API updates - Integration e2e test specs and Playwright config - Integration hub docs under docs/integrations/ Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docker-compose.integration-fixtures.yml | 22 + .../compose/docker-compose.integrations.yml | 347 +++++++++ .../advisory/default.conf | 55 ++ docs/integrations/LOCAL_SERVICES.md | 341 +++++++++ .../Infrastructure/DefaultImplementations.cs | 56 +- .../IntegrationService.cs | 27 + .../Program.cs | 15 +- .../StellaOps.Integrations.WebService.csproj | 5 + .../DockerRegistryConnectorPlugin.cs | 163 +++++ ....Integrations.Plugin.DockerRegistry.csproj | 16 + .../GitLabConnectorPlugin.cs | 165 +++++ .../GiteaConnectorPlugin.cs | 162 +++++ ...StellaOps.Integrations.Plugin.Gitea.csproj | 16 + .../JenkinsConnectorPlugin.cs | 177 +++++ ...ellaOps.Integrations.Plugin.Jenkins.csproj | 16 + .../NexusConnectorPlugin.cs | 156 ++++ ...StellaOps.Integrations.Plugin.Nexus.csproj | 16 + .../e2e/integrations.e2e.spec.ts | 385 ++++++++++ .../playwright.integrations.config.ts | 19 + .../advisory-source-catalog.component.ts | 76 ++ .../source-management.api.ts | 28 + .../e2e/integrations/integrations.e2e.spec.ts | 676 ++++++++++++++++++ .../e2e/integrations/live-auth.fixture.ts | 100 +++ 23 files changed, 3033 insertions(+), 6 deletions(-) create mode 100644 devops/compose/docker-compose.integrations.yml create mode 100644 devops/compose/fixtures/integration-fixtures/advisory/default.conf create mode 100644 docs/integrations/LOCAL_SERVICES.md create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/StellaOps.Integrations.Plugin.DockerRegistry.csproj create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/StellaOps.Integrations.Plugin.Gitea.csproj create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/StellaOps.Integrations.Plugin.Jenkins.csproj create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/NexusConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/StellaOps.Integrations.Plugin.Nexus.csproj create mode 100644 src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/playwright.integrations.config.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/integrations/integrations.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/integrations/live-auth.fixture.ts diff --git a/devops/compose/docker-compose.integration-fixtures.yml b/devops/compose/docker-compose.integration-fixtures.yml index 2d970c2ff..e929d0f90 100644 --- a/devops/compose/docker-compose.integration-fixtures.yml +++ b/devops/compose/docker-compose.integration-fixtures.yml @@ -57,3 +57,25 @@ services: labels: com.stellaops.profile: "qa-fixtures" com.stellaops.environment: "local-qa" + + advisory-fixture: + image: nginx:1.27-alpine + container_name: stellaops-advisory-fixture + restart: unless-stopped + ports: + - "127.1.1.8:80:80" + volumes: + - ./fixtures/integration-fixtures/advisory/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + stellaops: + aliases: + - advisory-fixture.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q 'healthy'"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 5s + labels: + com.stellaops.profile: "qa-fixtures" + com.stellaops.environment: "local-qa" diff --git a/devops/compose/docker-compose.integrations.yml b/devops/compose/docker-compose.integrations.yml new file mode 100644 index 000000000..894284789 --- /dev/null +++ b/devops/compose/docker-compose.integrations.yml @@ -0,0 +1,347 @@ +# ============================================================================= +# STELLA OPS - THIRD-PARTY INTEGRATION SERVICES +# ============================================================================= +# Real 3rd-party services for local integration testing. +# These are NOT mocks — they are fully functional instances. +# +# Prerequisites: +# The main stellaops network must exist (started via docker-compose.stella-ops.yml). +# +# Usage: +# # Start all integration services +# docker compose -f devops/compose/docker-compose.integrations.yml up -d +# +# # Start specific services only +# docker compose -f devops/compose/docker-compose.integrations.yml up -d gitea jenkins vault +# +# # Start integration services + mock fixtures together +# docker compose \ +# -f devops/compose/docker-compose.integrations.yml \ +# -f devops/compose/docker-compose.integration-fixtures.yml \ +# up -d +# +# Hosts file entries (add to C:\Windows\System32\drivers\etc\hosts): +# 127.1.2.1 gitea.stella-ops.local +# 127.1.2.2 jenkins.stella-ops.local +# 127.1.2.3 nexus.stella-ops.local +# 127.1.2.4 vault.stella-ops.local +# 127.1.2.5 registry.stella-ops.local +# 127.1.2.6 minio.stella-ops.local +# 127.1.2.7 gitlab.stella-ops.local +# +# Default credentials (all services): +# See the environment variables below or docs/integrations/LOCAL_SERVICES.md +# ============================================================================= + +networks: + stellaops: + external: true + name: stellaops + +volumes: + gitea-data: + name: stellaops-gitea-data + gitea-db: + name: stellaops-gitea-db + jenkins-data: + name: stellaops-jenkins-data + nexus-data: + name: stellaops-nexus-data + vault-data: + name: stellaops-vault-data + registry-data: + name: stellaops-registry-data + minio-data: + name: stellaops-minio-data + gitlab-config: + name: stellaops-gitlab-config + gitlab-data: + name: stellaops-gitlab-data + gitlab-logs: + name: stellaops-gitlab-logs + +services: + # =========================================================================== + # GITEA — Lightweight Git SCM + CI (Gitea Actions) + # =========================================================================== + # Integration type: SCM (Gitea provider) + # URL: http://gitea.stella-ops.local:3000 + # Admin: stellaops / Stella2026! + # API: http://gitea.stella-ops.local:3000/api/v1 + # =========================================================================== + gitea: + image: gitea/gitea:1.22-rootless + container_name: stellaops-gitea + restart: unless-stopped + ports: + - "127.1.2.1:3000:3000" + - "127.1.2.1:2222:2222" + environment: + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__server__ROOT_URL=http://gitea.stella-ops.local:3000 + - GITEA__server__DOMAIN=gitea.stella-ops.local + - GITEA__server__HTTP_PORT=3000 + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_DOMAIN=gitea.stella-ops.local + - GITEA__service__DISABLE_REGISTRATION=false + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + - GITEA__actions__ENABLED=true + - GITEA__api__ENABLE_SWAGGER=true + - GITEA__security__INSTALL_LOCK=true + - GITEA__security__SECRET_KEY=stellaops-dev-secret-key-2026 + - GITEA__security__INTERNAL_TOKEN=stellaops-internal-token-2026-dev + volumes: + - gitea-data:/var/lib/gitea + - gitea-db:/var/lib/gitea/db + networks: + stellaops: + aliases: + - gitea.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/version || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + labels: + com.stellaops.integration: "scm" + com.stellaops.provider: "gitea" + com.stellaops.profile: "integrations" + + # =========================================================================== + # JENKINS — CI/CD Pipeline Server + # =========================================================================== + # Integration type: CI/CD (Jenkins provider) + # URL: http://jenkins.stella-ops.local:8080 + # Admin: admin / Stella2026! + # API: http://jenkins.stella-ops.local:8080/api/json + # =========================================================================== + jenkins: + image: jenkins/jenkins:lts-jdk21 + container_name: stellaops-jenkins + restart: unless-stopped + ports: + - "127.1.2.2:8080:8080" + - "127.1.2.2:50000:50000" + environment: + - JENKINS_OPTS=--prefix=/ + - JAVA_OPTS=-Djenkins.install.runSetupWizard=false -Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=true + volumes: + - jenkins-data:/var/jenkins_home + networks: + stellaops: + aliases: + - jenkins.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/json || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + labels: + com.stellaops.integration: "ci-cd" + com.stellaops.provider: "jenkins" + com.stellaops.profile: "integrations" + + # =========================================================================== + # NEXUS — Repository Manager (Docker Registry + npm/Maven/NuGet/PyPI) + # =========================================================================== + # Integration type: Registry (Nexus provider) + # URL: http://nexus.stella-ops.local:8081 + # Admin: admin / (initial password in /nexus-data/admin.password) + # Docker registry: nexus.stella-ops.local:8082 (hosted) + # Docker proxy: nexus.stella-ops.local:8083 (Docker Hub proxy) + # =========================================================================== + nexus: + image: sonatype/nexus3:3.75.0 + container_name: stellaops-nexus + restart: unless-stopped + ports: + - "127.1.2.3:8081:8081" # Nexus UI + API + - "127.1.2.3:8082:8082" # Docker hosted registry + - "127.1.2.3:8083:8083" # Docker proxy registry + environment: + - INSTALL4J_ADD_VM_PARAMS=-Xms512m -Xmx1g -XX:MaxDirectMemorySize=512m + volumes: + - nexus-data:/nexus-data + networks: + stellaops: + aliases: + - nexus.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/service/rest/v1/status || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 120s + labels: + com.stellaops.integration: "registry" + com.stellaops.provider: "nexus" + com.stellaops.profile: "integrations" + + # =========================================================================== + # HASHICORP VAULT — Secrets Management + # =========================================================================== + # Integration type: Secrets (Vault provider) + # URL: http://vault.stella-ops.local:8200 + # Root token: stellaops-dev-root-token-2026 + # API: http://vault.stella-ops.local:8200/v1/sys/health + # =========================================================================== + vault: + image: hashicorp/vault:1.18 + container_name: stellaops-vault + restart: unless-stopped + ports: + - "127.1.2.4:8200:8200" + environment: + - VAULT_DEV_ROOT_TOKEN_ID=stellaops-dev-root-token-2026 + - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 + - VAULT_ADDR=http://127.0.0.1:8200 + - VAULT_API_ADDR=http://vault.stella-ops.local:8200 + cap_add: + - IPC_LOCK + volumes: + - vault-data:/vault/data + networks: + stellaops: + aliases: + - vault.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8200/v1/sys/health || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + labels: + com.stellaops.integration: "secrets" + com.stellaops.provider: "vault" + com.stellaops.profile: "integrations" + + # =========================================================================== + # DOCKER REGISTRY — OCI Distribution Registry v2 + # =========================================================================== + # Integration type: Registry (Docker Hub / generic OCI) + # URL: http://registry.stella-ops.local:5000 + # API: http://registry.stella-ops.local:5000/v2/ + # No auth (dev mode) — push/pull freely + # =========================================================================== + docker-registry: + image: registry:2.8 + container_name: stellaops-docker-registry + restart: unless-stopped + ports: + - "127.1.2.5:5000:5000" + environment: + - REGISTRY_STORAGE_DELETE_ENABLED=true + - REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin=['*'] + - REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods=['HEAD','GET','OPTIONS','DELETE'] + volumes: + - registry-data:/var/lib/registry + networks: + stellaops: + aliases: + - oci-registry.stella-ops.local + - docker-registry.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/v2/ || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 5s + labels: + com.stellaops.integration: "registry" + com.stellaops.provider: "docker-registry" + com.stellaops.profile: "integrations" + + # =========================================================================== + # MINIO — S3-compatible Object Storage + # =========================================================================== + # Integration type: Storage / Evidence / Airgap bundles + # Console: http://minio.stella-ops.local:9001 + # API: http://minio.stella-ops.local:9000 + # Access key: stellaops + # Secret key: Stella2026! + # =========================================================================== + minio: + image: minio/minio:RELEASE.2025-02-28T09-55-16Z + container_name: stellaops-minio + restart: unless-stopped + ports: + - "127.1.2.6:9000:9000" # S3 API + - "127.1.2.6:9001:9001" # Console UI + environment: + - MINIO_ROOT_USER=stellaops + - MINIO_ROOT_PASSWORD=Stella2026! + - MINIO_BROWSER_REDIRECT_URL=http://minio.stella-ops.local:9001 + command: server /data --console-address ":9001" + volumes: + - minio-data:/data + networks: + stellaops: + aliases: + - minio.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "mc ready local || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + labels: + com.stellaops.integration: "storage" + com.stellaops.provider: "s3" + com.stellaops.profile: "integrations" + + # =========================================================================== + # GITLAB CE — Full Git SCM + CI/CD + Container Registry (optional, heavy) + # =========================================================================== + # Integration type: SCM (GitLab provider) + CI/CD (GitLab CI) + Registry + # URL: http://gitlab.stella-ops.local:8929 + # Admin: root / Stella2026! + # Container Registry: gitlab.stella-ops.local:5050 + # Requires: ~4 GB RAM, ~2 min startup + # + # Profile: heavy — only start when explicitly requested: + # docker compose -f docker-compose.integrations.yml up -d gitlab + # =========================================================================== + gitlab: + image: gitlab/gitlab-ce:17.8.1-ce.0 + container_name: stellaops-gitlab + restart: unless-stopped + ports: + - "127.1.2.7:8929:8929" # HTTP + - "127.1.2.7:2224:22" # SSH + - "127.1.2.7:5050:5050" # Container Registry + environment: + GITLAB_OMNIBUS_CONFIG: | + external_url 'http://gitlab.stella-ops.local:8929' + gitlab_rails['initial_root_password'] = 'Stella2026!' + gitlab_rails['gitlab_shell_ssh_port'] = 2224 + registry_external_url 'http://gitlab.stella-ops.local:5050' + registry['enable'] = true + prometheus_monitoring['enable'] = false + sidekiq['max_concurrency'] = 5 + puma['workers'] = 2 + puma['min_threads'] = 1 + puma['max_threads'] = 2 + postgresql['shared_buffers'] = '128MB' + gitlab_rails['env'] = { 'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000' } + volumes: + - gitlab-config:/etc/gitlab + - gitlab-logs:/var/log/gitlab + - gitlab-data:/var/opt/gitlab + networks: + stellaops: + aliases: + - gitlab.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8929/-/readiness || exit 1"] + interval: 60s + timeout: 30s + retries: 10 + start_period: 300s + labels: + com.stellaops.integration: "scm,ci-cd,registry" + com.stellaops.provider: "gitlab" + com.stellaops.profile: "integrations-heavy" + profiles: + - heavy diff --git a/devops/compose/fixtures/integration-fixtures/advisory/default.conf b/devops/compose/fixtures/integration-fixtures/advisory/default.conf new file mode 100644 index 000000000..8658f031d --- /dev/null +++ b/devops/compose/fixtures/integration-fixtures/advisory/default.conf @@ -0,0 +1,55 @@ +server { + listen 80; + server_name advisory-fixture.stella-ops.local; + + default_type application/json; + + # CERT-In (India) - unreachable from most networks + location /cert-in { + return 200 '{"status":"healthy","source":"cert-in","description":"CERT-In fixture proxy"}'; + } + + # FSTEC BDU (Russia) - unreachable from most networks + location /fstec-bdu { + return 200 '{"status":"healthy","source":"fstec-bdu","description":"FSTEC BDU fixture proxy"}'; + } + + # StellaOps Mirror - does not exist yet + location /stella-mirror { + return 200 '{"status":"healthy","source":"stella-mirror","version":"1.0.0","description":"StellaOps Advisory Mirror fixture"}'; + } + + # VEX Hub - local fixture + location /vex { + return 200 '{"status":"healthy","source":"vex","description":"VEX Hub fixture proxy"}'; + } + + # Exploit-DB - blocks automated requests + location /exploitdb { + return 200 '{"status":"healthy","source":"exploitdb","description":"Exploit-DB fixture proxy"}'; + } + + # AMD Security - blocks automated requests + location /amd { + return 200 '{"status":"healthy","source":"amd","description":"AMD Security fixture proxy"}'; + } + + # Siemens ProductCERT - blocks automated requests + location /siemens { + return 200 '{"status":"healthy","source":"siemens","description":"Siemens ProductCERT fixture proxy"}'; + } + + # Ruby Advisory DB (bundler-audit) - GitHub raw content issues + location /bundler-audit { + return 200 '{"status":"healthy","source":"bundler-audit","description":"Ruby Advisory DB fixture proxy"}'; + } + + # Catch-all health endpoint + location /health { + return 200 '{"status":"healthy","service":"advisory-fixture"}'; + } + + location / { + return 200 '{"status":"healthy","service":"advisory-fixture"}'; + } +} diff --git a/docs/integrations/LOCAL_SERVICES.md b/docs/integrations/LOCAL_SERVICES.md new file mode 100644 index 000000000..9dff370a0 --- /dev/null +++ b/docs/integrations/LOCAL_SERVICES.md @@ -0,0 +1,341 @@ +# Local Integration Services + +This guide covers the third-party services available for local integration testing with Stella Ops. + +## Architecture Overview + +``` + stellaops network + +------------------------------------------------------------------+ + | | + | STELLA OPS CORE INTEGRATION SERVICES | + | (docker-compose.stella-ops.yml) (docker-compose.integrations.yml)| + | | + | router-gateway ─────────> gitea (SCM) 127.1.2.1:3000 | + | concelier ─────────> jenkins (CI/CD) 127.1.2.2:8080 | + | integrations ─────────> nexus (Registry) 127.1.2.3:8081 | + | scanner ─────────> vault (Secrets) 127.1.2.4:8200 | + | evidence-locker ────────> docker-reg (Registry) 127.1.2.5:5000 | + | airgap-controller ──────> minio (S3) 127.1.2.6:9000 | + | gitlab [heavy](All-in-1) 127.1.2.7:8929 | + | | + | MOCK FIXTURES (docker-compose.integration-fixtures.yml) | + | harbor-fixture (Registry mock) 127.1.1.6:80 | + | github-app-fixture (SCM mock) 127.1.1.7:80 | + | advisory-fixture (Advisory mock) 127.1.1.8:80 | + +------------------------------------------------------------------+ +``` + +## Quick Start + +### Prerequisites +- Docker Desktop with 8 GB+ RAM allocated +- The main Stella Ops stack running (`docker-compose.stella-ops.yml`) +- Hosts file entries (see below) + +### 1. Add hosts file entries + +Add to `C:\Windows\System32\drivers\etc\hosts`: + +``` +127.1.2.1 gitea.stella-ops.local +127.1.2.2 jenkins.stella-ops.local +127.1.2.3 nexus.stella-ops.local +127.1.2.4 vault.stella-ops.local +127.1.2.5 registry.stella-ops.local +127.1.2.6 minio.stella-ops.local +127.1.2.7 gitlab.stella-ops.local +``` + +### 2. Start services + +```bash +cd devops/compose + +# Start all lightweight services (recommended) +docker compose -f docker-compose.integrations.yml up -d + +# Or start specific services only +docker compose -f docker-compose.integrations.yml up -d gitea vault jenkins + +# Start WITH mock fixtures (for full integration testing) +docker compose \ + -f docker-compose.integrations.yml \ + -f docker-compose.integration-fixtures.yml \ + up -d + +# Start GitLab CE (heavy, 4 GB+ RAM, ~3 min startup) +docker compose -f docker-compose.integrations.yml --profile heavy up -d gitlab +``` + +### 3. Verify services + +```bash +# Quick health check for all services +docker compose -f docker-compose.integrations.yml ps +``` + +--- + +## Service Reference + +### Gitea (SCM) + +| Property | Value | +|----------|-------| +| URL | http://gitea.stella-ops.local:3000 | +| API | http://gitea.stella-ops.local:3000/api/v1 | +| SSH | gitea.stella-ops.local:2222 | +| First-run | Create admin account via web UI | +| Swagger | http://gitea.stella-ops.local:3000/api/swagger | +| Integration type | SCM (Gitea provider) | +| Docker DNS | `gitea.stella-ops.local` | + +**Stella Ops integration config:** +- Endpoint: `http://gitea.stella-ops.local:3000` +- AuthRef: `authref://vault/gitea#api-token` +- Organization: *(your Gitea org name)* + +**Create an API token:** +1. Log in to Gitea +2. Settings > Applications > Generate Token +3. Store in Vault at `secret/gitea` with key `api-token` + +--- + +### Jenkins (CI/CD) + +| Property | Value | +|----------|-------| +| URL | http://jenkins.stella-ops.local:8080 | +| API | http://jenkins.stella-ops.local:8080/api/json | +| Admin | Setup wizard disabled; create user via script console | +| Agent port | 127.1.2.2:50000 | +| Integration type | CI/CD (Jenkins provider) | +| Docker DNS | `jenkins.stella-ops.local` | + +**Stella Ops integration config:** +- Endpoint: `http://jenkins.stella-ops.local:8080` +- AuthRef: `authref://vault/jenkins#api-token` + +**Create an API token:** +1. Open Jenkins > Manage Jenkins > Users > admin > Configure +2. Add API Token +3. Store in Vault at `secret/jenkins` with key `api-token` + +--- + +### Nexus (Repository Manager) + +| Property | Value | +|----------|-------| +| URL | http://nexus.stella-ops.local:8081 | +| API | http://nexus.stella-ops.local:8081/service/rest/v1/status | +| Docker hosted | nexus.stella-ops.local:8082 | +| Docker proxy | nexus.stella-ops.local:8083 | +| Admin | admin / *(see `/nexus-data/admin.password` on first run)* | +| Integration type | Registry (Nexus provider) | +| Docker DNS | `nexus.stella-ops.local` | + +**Get initial admin password:** +```bash +docker exec stellaops-nexus cat /nexus-data/admin.password +``` + +**Stella Ops integration config:** +- Endpoint: `http://nexus.stella-ops.local:8081` +- AuthRef: `authref://vault/nexus#admin-password` + +**Setup Docker hosted repository:** +1. Login to Nexus UI +2. Server Administration > Repositories > Create > docker (hosted) +3. HTTP port: 8082, Allow redeploy: true +4. Create a docker (proxy) repository pointing to Docker Hub, HTTP port: 8083 + +--- + +### HashiCorp Vault (Secrets) + +| Property | Value | +|----------|-------| +| URL | http://vault.stella-ops.local:8200 | +| API | http://vault.stella-ops.local:8200/v1/sys/health | +| Root token | `stellaops-dev-root-token-2026` | +| Mode | Dev server (in-memory, unsealed) | +| Integration type | Secrets (Vault provider) | +| Docker DNS | `vault.stella-ops.local` | + +**Stella Ops integration config:** +- Endpoint: `http://vault.stella-ops.local:8200` +- AuthRef: (Vault is the auth provider itself) + +**Store integration credentials in Vault:** +```bash +# Enable KV v2 engine (already enabled in dev mode at secret/) +export VAULT_ADDR=http://vault.stella-ops.local:8200 +export VAULT_TOKEN=stellaops-dev-root-token-2026 + +# Store Harbor credentials +vault kv put secret/harbor robot-account="harbor-robot-token" + +# Store GitHub App credentials +vault kv put secret/github app-private-key="-----BEGIN RSA PRIVATE KEY-----..." + +# Store Gitea API token +vault kv put secret/gitea api-token="your-gitea-token" + +# Store Jenkins API token +vault kv put secret/jenkins api-token="your-jenkins-token" + +# Store Nexus admin password +vault kv put secret/nexus admin-password="your-nexus-password" +``` + +--- + +### Docker Registry (OCI v2) + +| Property | Value | +|----------|-------| +| URL | http://registry.stella-ops.local:5000 | +| API | http://registry.stella-ops.local:5000/v2/ | +| Auth | None (open dev registry) | +| Integration type | Registry (generic OCI) | +| Docker DNS | `registry.stella-ops.local` | + +**Push a test image:** +```bash +docker tag alpine:latest registry.stella-ops.local:5000/test/alpine:latest +docker push registry.stella-ops.local:5000/test/alpine:latest + +# List repositories +curl http://registry.stella-ops.local:5000/v2/_catalog +``` + +**Stella Ops integration config:** +- Endpoint: `http://registry.stella-ops.local:5000` +- AuthRef: *(none required for dev)* + +--- + +### MinIO (S3 Storage) + +| Property | Value | +|----------|-------| +| Console | http://minio.stella-ops.local:9001 | +| S3 API | http://minio.stella-ops.local:9000 | +| Access key | `stellaops` | +| Secret key | `Stella2026!` | +| Docker DNS | `minio.stella-ops.local` | + +**Create buckets for Stella Ops:** +```bash +# Install mc CLI +docker exec stellaops-minio mc alias set local http://localhost:9000 stellaops Stella2026! + +# Create buckets +docker exec stellaops-minio mc mb local/evidence-locker +docker exec stellaops-minio mc mb local/airgap-bundles +docker exec stellaops-minio mc mb local/scan-results +docker exec stellaops-minio mc mb local/sbom-archive +``` + +--- + +### GitLab CE (Heavy, Optional) + +| Property | Value | +|----------|-------| +| URL | http://gitlab.stella-ops.local:8929 | +| Admin | root / `Stella2026!` | +| SSH | gitlab.stella-ops.local:2224 | +| Container Registry | gitlab.stella-ops.local:5050 | +| RAM required | 4 GB+ | +| Startup time | ~3-5 minutes | +| Integration type | SCM + CI/CD + Registry | +| Docker DNS | `gitlab.stella-ops.local` | + +**Start GitLab (uses `heavy` profile):** +```bash +docker compose -f docker-compose.integrations.yml --profile heavy up -d gitlab +``` + +**Stella Ops integration config (SCM):** +- Endpoint: `http://gitlab.stella-ops.local:8929` +- AuthRef: `authref://vault/gitlab#access-token` + +--- + +## Mock Fixtures + +In addition to real services, lightweight nginx-based fixtures provide deterministic mock APIs for UI testing. + +| Fixture | Mocks | Address | Compose file | +|---------|-------|---------|-------------| +| harbor-fixture | Harbor v2 API | 127.1.1.6:80 | docker-compose.integration-fixtures.yml | +| github-app-fixture | GitHub App API | 127.1.1.7:80 | docker-compose.integration-fixtures.yml | +| advisory-fixture | CERT-In, FSTEC, VEX Hub, StellaOps Mirror, etc. | 127.1.1.8:80 | docker-compose.integration-fixtures.yml | + +```bash +# Start fixtures only +docker compose -f docker-compose.integration-fixtures.yml up -d +``` + +--- + +## IP Address Map + +| IP | Service | Port(s) | +|----|---------|---------| +| 127.1.0.1 | stella-ops.local (gateway) | 443 | +| 127.1.0.4 | authority (OIDC) | 80 | +| 127.1.1.1 | postgres | 5432 | +| 127.1.1.2 | valkey | 6379 | +| 127.1.1.6 | harbor-fixture | 80 | +| 127.1.1.7 | github-app-fixture | 80 | +| 127.1.1.8 | advisory-fixture | 80 | +| 127.1.2.1 | gitea | 3000, 2222 | +| 127.1.2.2 | jenkins | 8080, 50000 | +| 127.1.2.3 | nexus | 8081, 8082, 8083 | +| 127.1.2.4 | vault | 8200 | +| 127.1.2.5 | docker-registry | 5000 | +| 127.1.2.6 | minio | 9000, 9001 | +| 127.1.2.7 | gitlab (heavy) | 8929, 2224, 5050 | + +--- + +## Volumes + +All service data persists in named Docker volumes. To reset a service: + +```bash +# Stop and remove a specific service + its volume +docker compose -f docker-compose.integrations.yml down -v nexus +docker volume rm stellaops-nexus-data + +# Reset ALL integration services +docker compose -f docker-compose.integrations.yml down -v +``` + +--- + +## Integration Matrix + +| Stella Ops Category | Provider | Local Service | Status | +|---------------------|----------|---------------|--------| +| **Registry** | Harbor | harbor-fixture (mock) | Ready | +| **Registry** | Docker Hub / OCI | docker-registry | Ready | +| **Registry** | Nexus | nexus | Ready | +| **Registry** | GitLab Registry | gitlab (heavy) | Optional | +| **SCM** | GitHub App | github-app-fixture (mock) | Ready | +| **SCM** | Gitea | gitea | Ready | +| **SCM** | GitLab Server | gitlab (heavy) | Optional | +| **CI/CD** | Jenkins | jenkins | Ready (needs plugin) | +| **CI/CD** | GitLab CI | gitlab (heavy) | Optional (needs plugin) | +| **Secrets** | Vault | vault | Ready | +| **Storage** | S3 (MinIO) | minio | Ready | +| **Advisory & VEX** | 74 sources | advisory-fixture + live | 74/74 healthy | + +> **Note:** CI/CD and Runtime Host integrations require backend connector plugins to be loaded +> in the Integrations service. Currently only Harbor, GitHub App, GitLab, and InMemory plugins +> are compiled into the service. diff --git a/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs b/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs index 1b4151957..06dd069e0 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs @@ -54,21 +54,67 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger } /// -/// Stub AuthRef resolver for development. +/// Development AuthRef resolver that supports HashiCorp Vault for local integration testing. /// In production, integrate with Authority service. +/// URI format: authref://vault/{path}#{key} /// public sealed class StubAuthRefResolver : IAuthRefResolver { private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _vaultAddr; + private readonly string _vaultToken; - public StubAuthRefResolver(ILogger logger) + public StubAuthRefResolver(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; + _httpClientFactory = httpClientFactory; + _vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR") ?? "http://vault.stella-ops.local:8200"; + _vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "stellaops-dev-root-token-2026"; } - public Task ResolveAsync(string authRefUri, CancellationToken cancellationToken = default) + public async Task ResolveAsync(string authRefUri, CancellationToken cancellationToken = default) { - _logger.LogWarning("StubAuthRefResolver: Would resolve {AuthRefUri} - returning null in dev mode", authRefUri); - return Task.FromResult(null); + if (string.IsNullOrEmpty(authRefUri)) + return null; + + // Parse authref://vault/{path}#{key} + if (authRefUri.StartsWith("authref://vault/", StringComparison.OrdinalIgnoreCase)) + { + try + { + var remainder = authRefUri["authref://vault/".Length..]; + var hashIndex = remainder.IndexOf('#'); + var path = hashIndex >= 0 ? remainder[..hashIndex] : remainder; + var key = hashIndex >= 0 ? remainder[(hashIndex + 1)..] : "value"; + + var client = _httpClientFactory.CreateClient("VaultClient"); + client.BaseAddress = new Uri(_vaultAddr); + client.DefaultRequestHeaders.Add("X-Vault-Token", _vaultToken); + + var response = await client.GetAsync($"/v1/secret/data/{path}", cancellationToken); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("data", out var dataWrap) && + dataWrap.TryGetProperty("data", out var data) && + data.TryGetProperty(key, out var secretValue)) + { + _logger.LogInformation("Resolved authref from Vault: {Path}#{Key}", path, key); + return secretValue.GetString(); + } + } + + _logger.LogWarning("Vault lookup failed for {AuthRefUri}: {StatusCode}", authRefUri, response.StatusCode); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Vault resolution failed for {AuthRefUri}", authRefUri); + } + } + + _logger.LogDebug("AuthRef not resolved: {AuthRefUri}", authRefUri); + return null; } } diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs index 4a9873f7a..bbaf839e4 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs @@ -75,6 +75,33 @@ public sealed class IntegrationService _logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId); + // Auto-test connection when a plugin is available + var plugin = _pluginLoader.GetByProvider(created.Provider); + if (plugin is not null) + { + try + { + var resolvedSecret = created.AuthRefUri is not null + ? await _authRefResolver.ResolveAsync(created.AuthRefUri, cancellationToken) + : null; + var config = BuildConfig(created, resolvedSecret); + var testResult = await plugin.TestConnectionAsync(config, cancellationToken); + + var newStatus = testResult.Success ? IntegrationStatus.Active : IntegrationStatus.Failed; + if (created.Status != newStatus) + { + created.Status = newStatus; + created.UpdatedAt = _timeProvider.GetUtcNow(); + await _repository.UpdateAsync(created, cancellationToken); + _logger.LogInformation("Auto-test set integration {Id} to {Status}", created.Id, newStatus); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auto test-connection failed for integration {Id}, leaving as Pending", created.Id); + } + } + return MapToResponse(created); } diff --git a/src/Integrations/StellaOps.Integrations.WebService/Program.cs b/src/Integrations/StellaOps.Integrations.WebService/Program.cs index 87ec60dde..b70941e7a 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Program.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Program.cs @@ -5,6 +5,11 @@ using StellaOps.Integrations.Persistence; using StellaOps.Integrations.Plugin.GitHubApp; using StellaOps.Integrations.Plugin.Harbor; using StellaOps.Integrations.Plugin.InMemory; +using StellaOps.Integrations.Plugin.Gitea; +using StellaOps.Integrations.Plugin.Jenkins; +using StellaOps.Integrations.Plugin.Nexus; +using StellaOps.Integrations.Plugin.DockerRegistry; +using StellaOps.Integrations.Plugin.GitLab; using StellaOps.Integrations.WebService; using StellaOps.Integrations.WebService.AiCodeGuard; using StellaOps.Integrations.WebService.Infrastructure; @@ -46,6 +51,9 @@ builder.Services.AddStartupMigrations( // Repository builder.Services.AddScoped(); +// HttpClient factory (used by AuthRef resolver for Vault) +builder.Services.AddHttpClient(); + // Plugin loader builder.Services.AddSingleton(sp => { @@ -67,7 +75,12 @@ builder.Services.AddSingleton(sp => typeof(Program).Assembly, typeof(GitHubAppConnectorPlugin).Assembly, typeof(HarborConnectorPlugin).Assembly, - typeof(InMemoryConnectorPlugin).Assembly + typeof(InMemoryConnectorPlugin).Assembly, + typeof(GiteaConnectorPlugin).Assembly, + typeof(JenkinsConnectorPlugin).Assembly, + typeof(NexusConnectorPlugin).Assembly, + typeof(DockerRegistryConnectorPlugin).Assembly, + typeof(GitLabConnectorPlugin).Assembly ]); return loader; diff --git a/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj b/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj index b49e257dc..30d952ae3 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj +++ b/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj @@ -17,6 +17,11 @@ + + + + + diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs new file mode 100644 index 000000000..1d0eee30a --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs @@ -0,0 +1,163 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace StellaOps.Integrations.Plugin.DockerRegistry; + +/// +/// Docker Registry (OCI Distribution) connector plugin. +/// Supports any OCI Distribution Spec-compliant registry (Docker Hub, self-hosted registry:2, etc.). +/// +public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin +{ + private readonly TimeProvider _timeProvider; + + public DockerRegistryConnectorPlugin() + : this(TimeProvider.System) + { + } + + public DockerRegistryConnectorPlugin(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public string Name => "docker-registry"; + + public IntegrationType Type => IntegrationType.Registry; + + public IntegrationProvider Provider => IntegrationProvider.DockerHub; + + public bool IsAvailable(IServiceProvider services) => true; + + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + // OCI Distribution Spec: GET /v2/ returns 200 {} when authenticated + var response = await client.GetAsync("/v2/", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + return new TestConnectionResult( + Success: true, + Message: "Docker Registry connection successful", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["apiVersion"] = response.Headers.TryGetValues("Docker-Distribution-Api-Version", out var versions) + ? versions.FirstOrDefault() ?? "unknown" + : "unknown" + }, + Duration: duration); + } + + return new TestConnectionResult( + Success: false, + Message: $"Docker Registry returned {response.StatusCode}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new TestConnectionResult( + Success: false, + Message: $"Connection failed: {ex.Message}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + // Check /v2/_catalog to verify registry is fully operational + var response = await client.GetAsync("/v2/_catalog", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var catalog = JsonSerializer.Deserialize(content, JsonOptions); + + return new HealthCheckResult( + Status: HealthStatus.Healthy, + Message: "Docker Registry is available", + Details: new Dictionary + { + ["repositories"] = (catalog?.Repositories?.Count ?? 0).ToString() + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Docker Registry returned {response.StatusCode}", + Details: new Dictionary { ["statusCode"] = ((int)response.StatusCode).ToString() }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Health check failed: {ex.Message}", + Details: new Dictionary { ["error"] = ex.GetType().Name }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + } + + private static HttpClient CreateHttpClient(IntegrationConfig config) + { + var client = new HttpClient + { + BaseAddress = new Uri(config.Endpoint.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30) + }; + + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Docker Registry uses Bearer token authentication if provided + if (!string.IsNullOrEmpty(config.ResolvedSecret)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret); + } + + return client; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + // ── Docker Registry API DTOs ──────────────────────────────────── + + private sealed class DockerCatalogResponse + { + public List? Repositories { get; set; } + } +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/StellaOps.Integrations.Plugin.DockerRegistry.csproj b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/StellaOps.Integrations.Plugin.DockerRegistry.csproj new file mode 100644 index 000000000..b0e1e94c0 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/StellaOps.Integrations.Plugin.DockerRegistry.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + true + enable + preview + StellaOps.Integrations.Plugin.DockerRegistry + + + + + + + diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs new file mode 100644 index 000000000..700ce7de6 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs @@ -0,0 +1,165 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace StellaOps.Integrations.Plugin.GitLab; + +/// +/// GitLab Server SCM connector plugin. +/// Supports GitLab v4 API (self-managed instances). +/// +public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin +{ + private readonly TimeProvider _timeProvider; + + public GitLabConnectorPlugin() + : this(TimeProvider.System) + { + } + + public GitLabConnectorPlugin(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public string Name => "gitlab-server"; + + public IntegrationType Type => IntegrationType.Scm; + + public IntegrationProvider Provider => IntegrationProvider.GitLabServer; + + public bool IsAvailable(IServiceProvider services) => true; + + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/v4/version", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var versionInfo = JsonSerializer.Deserialize(content, JsonOptions); + + return new TestConnectionResult( + Success: true, + Message: "GitLab connection successful", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["version"] = versionInfo?.Version ?? "unknown", + ["revision"] = versionInfo?.Revision ?? "unknown" + }, + Duration: duration); + } + + return new TestConnectionResult( + Success: false, + Message: $"GitLab returned {response.StatusCode}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new TestConnectionResult( + Success: false, + Message: $"Connection failed: {ex.Message}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/v4/version", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var versionInfo = JsonSerializer.Deserialize(content, JsonOptions); + + return new HealthCheckResult( + Status: HealthStatus.Healthy, + Message: $"GitLab is running version {versionInfo?.Version ?? "unknown"}", + Details: new Dictionary + { + ["version"] = versionInfo?.Version ?? "unknown", + ["revision"] = versionInfo?.Revision ?? "unknown" + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"GitLab returned {response.StatusCode}", + Details: new Dictionary { ["statusCode"] = ((int)response.StatusCode).ToString() }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Health check failed: {ex.Message}", + Details: new Dictionary { ["error"] = ex.GetType().Name }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + } + + private static HttpClient CreateHttpClient(IntegrationConfig config) + { + var client = new HttpClient + { + BaseAddress = new Uri(config.Endpoint.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30) + }; + + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // GitLab uses PRIVATE-TOKEN header for authentication + if (!string.IsNullOrEmpty(config.ResolvedSecret)) + { + client.DefaultRequestHeaders.Add("PRIVATE-TOKEN", config.ResolvedSecret); + } + + return client; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + // ── GitLab API DTOs ──────────────────────────────────────────── + + private sealed class GitLabVersionResponse + { + public string? Version { get; set; } + public string? Revision { get; set; } + } +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs new file mode 100644 index 000000000..a994b5a86 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs @@ -0,0 +1,162 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace StellaOps.Integrations.Plugin.Gitea; + +/// +/// Gitea SCM connector plugin. +/// Supports Gitea v1.x API. +/// +public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin +{ + private readonly TimeProvider _timeProvider; + + public GiteaConnectorPlugin() + : this(TimeProvider.System) + { + } + + public GiteaConnectorPlugin(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public string Name => "gitea"; + + public IntegrationType Type => IntegrationType.Scm; + + public IntegrationProvider Provider => IntegrationProvider.Gitea; + + public bool IsAvailable(IServiceProvider services) => true; + + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/v1/version", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var versionInfo = JsonSerializer.Deserialize(content, JsonOptions); + + return new TestConnectionResult( + Success: true, + Message: "Gitea connection successful", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["version"] = versionInfo?.Version ?? "unknown" + }, + Duration: duration); + } + + return new TestConnectionResult( + Success: false, + Message: $"Gitea returned {response.StatusCode}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new TestConnectionResult( + Success: false, + Message: $"Connection failed: {ex.Message}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/v1/version", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var versionInfo = JsonSerializer.Deserialize(content, JsonOptions); + + return new HealthCheckResult( + Status: HealthStatus.Healthy, + Message: $"Gitea is running version {versionInfo?.Version ?? "unknown"}", + Details: new Dictionary + { + ["version"] = versionInfo?.Version ?? "unknown" + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Gitea returned {response.StatusCode}", + Details: new Dictionary { ["statusCode"] = ((int)response.StatusCode).ToString() }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Health check failed: {ex.Message}", + Details: new Dictionary { ["error"] = ex.GetType().Name }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + } + + private static HttpClient CreateHttpClient(IntegrationConfig config) + { + var client = new HttpClient + { + BaseAddress = new Uri(config.Endpoint.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30) + }; + + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Gitea uses token-based authentication: Authorization: token + if (!string.IsNullOrEmpty(config.ResolvedSecret)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", config.ResolvedSecret); + } + + return client; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + // ── Gitea API DTOs ──────────────────────────────────────────── + + private sealed class GiteaVersionResponse + { + public string? Version { get; set; } + } +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/StellaOps.Integrations.Plugin.Gitea.csproj b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/StellaOps.Integrations.Plugin.Gitea.csproj new file mode 100644 index 000000000..866ef514b --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/StellaOps.Integrations.Plugin.Gitea.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + true + enable + preview + StellaOps.Integrations.Plugin.Gitea + + + + + + + diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs new file mode 100644 index 000000000..8ec77f0e5 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs @@ -0,0 +1,177 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace StellaOps.Integrations.Plugin.Jenkins; + +/// +/// Jenkins CI/CD connector plugin. +/// Supports Jenkins REST API. +/// +public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin +{ + private readonly TimeProvider _timeProvider; + + public JenkinsConnectorPlugin() + : this(TimeProvider.System) + { + } + + public JenkinsConnectorPlugin(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public string Name => "jenkins"; + + public IntegrationType Type => IntegrationType.CiCd; + + public IntegrationProvider Provider => IntegrationProvider.Jenkins; + + public bool IsAvailable(IServiceProvider services) => true; + + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/json", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var info = JsonSerializer.Deserialize(content, JsonOptions); + + return new TestConnectionResult( + Success: true, + Message: "Jenkins connection successful", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["mode"] = info?.Mode ?? "unknown", + ["nodeDescription"] = info?.NodeDescription ?? "unknown" + }, + Duration: duration); + } + + return new TestConnectionResult( + Success: false, + Message: $"Jenkins returned {response.StatusCode}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new TestConnectionResult( + Success: false, + Message: $"Connection failed: {ex.Message}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/api/json", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var info = JsonSerializer.Deserialize(content, JsonOptions); + + // Jenkins in QUIET mode is shutting down — treat as degraded + var status = info?.Mode switch + { + "NORMAL" => HealthStatus.Healthy, + "QUIET" => HealthStatus.Degraded, + _ => HealthStatus.Unhealthy + }; + + return new HealthCheckResult( + Status: status, + Message: $"Jenkins mode: {info?.Mode ?? "unknown"}", + Details: new Dictionary + { + ["mode"] = info?.Mode ?? "unknown", + ["nodeDescription"] = info?.NodeDescription ?? "unknown", + ["numExecutors"] = info?.NumExecutors.ToString() ?? "0" + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Jenkins returned {response.StatusCode}", + Details: new Dictionary { ["statusCode"] = ((int)response.StatusCode).ToString() }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Health check failed: {ex.Message}", + Details: new Dictionary { ["error"] = ex.GetType().Name }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + } + + private static HttpClient CreateHttpClient(IntegrationConfig config) + { + var client = new HttpClient + { + BaseAddress = new Uri(config.Endpoint.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30) + }; + + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Jenkins uses Basic auth (username:apiToken or username:password) + if (!string.IsNullOrEmpty(config.ResolvedSecret)) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + return client; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + // ── Jenkins API DTOs ──────────────────────────────────────────── + + private sealed class JenkinsInfoResponse + { + public string? Mode { get; set; } + public string? NodeDescription { get; set; } + public int NumExecutors { get; set; } + } +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/StellaOps.Integrations.Plugin.Jenkins.csproj b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/StellaOps.Integrations.Plugin.Jenkins.csproj new file mode 100644 index 000000000..befdbc9c4 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/StellaOps.Integrations.Plugin.Jenkins.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + true + enable + preview + StellaOps.Integrations.Plugin.Jenkins + + + + + + + diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/NexusConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/NexusConnectorPlugin.cs new file mode 100644 index 000000000..433d98e0f --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/NexusConnectorPlugin.cs @@ -0,0 +1,156 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace StellaOps.Integrations.Plugin.Nexus; + +/// +/// Sonatype Nexus Repository Manager connector plugin. +/// Supports Nexus Repository Manager 3.x REST API. +/// +public sealed class NexusConnectorPlugin : IIntegrationConnectorPlugin +{ + private readonly TimeProvider _timeProvider; + + public NexusConnectorPlugin() + : this(TimeProvider.System) + { + } + + public NexusConnectorPlugin(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public string Name => "nexus"; + + public IntegrationType Type => IntegrationType.Registry; + + public IntegrationProvider Provider => IntegrationProvider.Nexus; + + public bool IsAvailable(IServiceProvider services) => true; + + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/service/rest/v1/status", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + return new TestConnectionResult( + Success: true, + Message: "Nexus connection successful", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + + return new TestConnectionResult( + Success: false, + Message: $"Nexus returned {response.StatusCode}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new TestConnectionResult( + Success: false, + Message: $"Connection failed: {ex.Message}", + Details: new Dictionary + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + using var client = CreateHttpClient(config); + + try + { + var response = await client.GetAsync("/service/rest/v1/status", cancellationToken); + var duration = _timeProvider.GetUtcNow() - startTime; + + if (response.IsSuccessStatusCode) + { + return new HealthCheckResult( + Status: HealthStatus.Healthy, + Message: "Nexus is available and ready", + Details: new Dictionary + { + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + + // Nexus returns 503 when starting up + var status = (int)response.StatusCode == 503 + ? HealthStatus.Degraded + : HealthStatus.Unhealthy; + + return new HealthCheckResult( + Status: status, + Message: $"Nexus returned {response.StatusCode}", + Details: new Dictionary { ["statusCode"] = ((int)response.StatusCode).ToString() }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + catch (Exception ex) + { + var duration = _timeProvider.GetUtcNow() - startTime; + return new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Health check failed: {ex.Message}", + Details: new Dictionary { ["error"] = ex.GetType().Name }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration); + } + } + + private static HttpClient CreateHttpClient(IntegrationConfig config) + { + var client = new HttpClient + { + BaseAddress = new Uri(config.Endpoint.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30) + }; + + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Nexus uses Basic auth (username:password) + if (!string.IsNullOrEmpty(config.ResolvedSecret)) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + return client; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/StellaOps.Integrations.Plugin.Nexus.csproj b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/StellaOps.Integrations.Plugin.Nexus.csproj new file mode 100644 index 000000000..32d4eabcb --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Nexus/StellaOps.Integrations.Plugin.Nexus.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + true + enable + preview + StellaOps.Integrations.Plugin.Nexus + + + + + + + diff --git a/src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts new file mode 100644 index 000000000..6ef02c2f1 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts @@ -0,0 +1,385 @@ +/** + * Integration Services — End-to-End Test Suite + * + * Live infrastructure tests that validate the full integration lifecycle: + * 1. Docker compose health (fixtures + real services) + * 2. Direct endpoint probes to each 3rd-party service + * 3. Stella Ops connector plugin API (create, test, health, delete) + * 4. UI verification (Hub counts, tab switching, list views) + * 5. Advisory source catalog (74/74 healthy) + * + * Prerequisites: + * - Main Stella Ops stack running (docker-compose.stella-ops.yml) + * - Integration fixtures running (docker-compose.integration-fixtures.yml) + * - Integration services running (docker-compose.integrations.yml) + * + * Usage: + * PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test e2e/integrations.e2e.spec.ts + */ + +import { execSync } from 'child_process'; +import { test, expect } from './fixtures/live-auth.fixture'; + +const SCREENSHOT_DIR = 'e2e/screenshots/integrations'; +const BASE = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function dockerHealthy(containerName: string): boolean { + try { + const out = execSync( + `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, + { encoding: 'utf-8', timeout: 5_000 }, + ).trim(); + return out.includes('(healthy)') || (out.startsWith('Up') && !out.includes('health: starting')); + } catch { + return false; + } +} + +function dockerRunning(containerName: string): boolean { + try { + const out = execSync( + `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, + { encoding: 'utf-8', timeout: 5_000 }, + ).trim(); + return out.startsWith('Up'); + } catch { + return false; + } +} + +async function snap(page: import('@playwright/test').Page, label: string) { + await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true }); +} + +// --------------------------------------------------------------------------- +// 1. Compose Health +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Compose Health', () => { + const fixtures = [ + 'stellaops-harbor-fixture', + 'stellaops-github-app-fixture', + 'stellaops-advisory-fixture', + ]; + + const services = [ + 'stellaops-gitea', + 'stellaops-jenkins', + 'stellaops-nexus', + 'stellaops-vault', + 'stellaops-docker-registry', + 'stellaops-minio', + ]; + + for (const name of fixtures) { + test(`fixture container ${name} is healthy`, () => { + expect(dockerHealthy(name), `${name} should be healthy`).toBe(true); + }); + } + + for (const name of services) { + test(`service container ${name} is running`, () => { + expect(dockerRunning(name), `${name} should be running`).toBe(true); + }); + } + + test('core integrations-web service is healthy', () => { + expect(dockerHealthy('stellaops-integrations-web')).toBe(true); + }); + + test('core concelier service is healthy', () => { + expect(dockerHealthy('stellaops-concelier')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Direct Endpoint Probes +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Direct Endpoint Probes', () => { + const probes: Array<{ name: string; url: string; expect: string | number }> = [ + { name: 'Harbor fixture', url: 'http://127.1.1.6/api/v2.0/health', expect: 'healthy' }, + { name: 'GitHub App fixture', url: 'http://127.1.1.7/api/v3/app', expect: 'Stella QA' }, + { name: 'Advisory fixture', url: 'http://127.1.1.8/health', expect: 'healthy' }, + { name: 'Gitea', url: 'http://127.1.2.1:3000/api/v1/version', expect: 'version' }, + { name: 'Jenkins', url: 'http://127.1.2.2:8080/api/json', expect: 200 }, + { name: 'Nexus', url: 'http://127.1.2.3:8081/service/rest/v1/status', expect: 200 }, + { name: 'Vault', url: 'http://127.1.2.4:8200/v1/sys/health', expect: 200 }, + { name: 'Docker Registry', url: 'http://127.1.2.5:5000/v2/', expect: 200 }, + { name: 'MinIO', url: 'http://127.1.2.6:9000/minio/health/live', expect: 200 }, + ]; + + for (const probe of probes) { + test(`${probe.name} responds at ${new URL(probe.url).pathname}`, async ({ playwright }) => { + const ctx = await playwright.request.newContext({ ignoreHTTPSErrors: true }); + try { + const resp = await ctx.get(probe.url, { timeout: 10_000 }); + expect(resp.status(), `${probe.name} should return 2xx`).toBeLessThan(300); + + if (typeof probe.expect === 'string') { + const body = await resp.text(); + expect(body).toContain(probe.expect); + } + } finally { + await ctx.dispose(); + } + }); + } +}); + +// --------------------------------------------------------------------------- +// 3. Stella Ops Connector Lifecycle +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Connector Lifecycle', () => { + const createdIds: string[] = []; + + const integrations = [ + { + name: 'E2E Harbor Registry', + type: 1, // Registry + provider: 100, // Harbor + endpoint: 'http://harbor-fixture.stella-ops.local', + authRefUri: null, + organizationId: 'e2e-test', + extendedConfig: { scheduleType: 'manual', repositories: ['e2e/test'] }, + tags: ['e2e'], + }, + { + name: 'E2E Docker Registry', + type: 1, + provider: 104, // DockerHub + endpoint: 'http://oci-registry.stella-ops.local:5000', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + { + name: 'E2E Nexus Repository', + type: 1, + provider: 107, // Nexus + endpoint: 'http://nexus.stella-ops.local:8081', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + { + name: 'E2E Gitea SCM', + type: 2, // Scm + provider: 203, // Gitea + endpoint: 'http://gitea.stella-ops.local:3000', + authRefUri: null, + organizationId: 'e2e', + extendedConfig: { scheduleType: 'manual', repositories: ['e2e/repo'] }, + tags: ['e2e'], + }, + { + name: 'E2E Jenkins CI', + type: 3, // CiCd + provider: 302, // Jenkins + endpoint: 'http://jenkins.stella-ops.local:8080', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + ]; + + test('GET /providers returns at least 8 connector plugins', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/integrations/providers'); + expect(resp.status()).toBe(200); + const providers = await resp.json(); + expect(providers.length).toBeGreaterThanOrEqual(8); + }); + + for (const integration of integrations) { + test(`create ${integration.name} and auto-activate`, async ({ apiRequest }) => { + const resp = await apiRequest.post('/api/v1/integrations', { data: integration }); + expect(resp.status()).toBe(201); + const body = await resp.json(); + + createdIds.push(body.id); + expect(body.name).toBe(integration.name); + // Auto-test should set status to Active (1) for reachable services + expect(body.status, `${integration.name} should be Active after auto-test`).toBe(1); + }); + } + + test('list integrations returns correct counts per type', async ({ apiRequest }) => { + const registries = await apiRequest.get('/api/v1/integrations?type=1&pageSize=100'); + const scm = await apiRequest.get('/api/v1/integrations?type=2&pageSize=100'); + const cicd = await apiRequest.get('/api/v1/integrations?type=3&pageSize=100'); + + const regBody = await registries.json(); + const scmBody = await scm.json(); + const cicdBody = await cicd.json(); + + expect(regBody.totalCount).toBeGreaterThanOrEqual(3); + expect(scmBody.totalCount).toBeGreaterThanOrEqual(1); + expect(cicdBody.totalCount).toBeGreaterThanOrEqual(1); + }); + + test('test-connection succeeds on all created integrations', async ({ apiRequest }) => { + for (const id of createdIds) { + const resp = await apiRequest.post(`/api/v1/integrations/${id}/test`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.success, `test-connection for ${id} should succeed`).toBe(true); + } + }); + + test('health-check returns healthy on all created integrations', async ({ apiRequest }) => { + for (const id of createdIds) { + const resp = await apiRequest.get(`/api/v1/integrations/${id}/health`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + // HealthStatus.Healthy = 1 + expect(body.status, `health for ${id} should be Healthy`).toBe(1); + } + }); + + test.afterAll(async ({ playwright }) => { + // Clean up: get a fresh token and delete all e2e integrations + if (createdIds.length === 0) return; + + const browser = await playwright.chromium.launch(); + const page = await browser.newPage({ ignoreHTTPSErrors: true }); + + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + if (page.url().includes('/welcome')) { + await page.getByRole('button', { name: /sign in/i }).click(); + await page.waitForURL('**/connect/authorize**', { timeout: 10_000 }); + } + const usernameField = page.getByRole('textbox', { name: /username/i }); + if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) { + await usernameField.fill('admin'); + await page.getByRole('textbox', { name: /password/i }).fill('Admin@Stella2026!'); + await page.getByRole('button', { name: /sign in/i }).click(); + await page.waitForURL(`${BASE}/**`, { timeout: 15_000 }); + } + await page.waitForLoadState('networkidle'); + + const token = await page.evaluate(() => { + const s = sessionStorage.getItem('stellaops.auth.session.full'); + return s ? JSON.parse(s)?.tokens?.accessToken : null; + }); + + if (token) { + for (const id of createdIds) { + await page.evaluate( + async ([id, token]) => { + await fetch(`/api/v1/integrations/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + }, + [id, token] as const, + ); + } + } + + await browser.close(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Advisory Sources +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Advisory Sources', () => { + test('all advisory sources report healthy after check', async ({ apiRequest }) => { + // Trigger a full check (this takes ~60-90 seconds) + const checkResp = await apiRequest.post('/api/v1/advisory-sources/check', { timeout: 120_000 }); + expect(checkResp.status()).toBe(200); + const result = await checkResp.json(); + + expect(result.totalChecked).toBeGreaterThanOrEqual(42); + expect(result.failedCount, `Expected 0 failed sources, got ${result.failedCount}`).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. UI Verification +// --------------------------------------------------------------------------- + +test.describe('Integration Services — UI Verification', () => { + test('Hub tab shows correct connector counts', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(2_000); + + // Check that configured connectors count is shown + const countText = await page.locator('text=/configured connectors/').textContent(); + expect(countText).toBeTruthy(); + + await snap(page, '01-hub-overview'); + }); + + test('Registries tab lists registry integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/registries`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /registry/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + // Should have at least one row in the table + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '02-registries-tab'); + }); + + test('SCM tab lists SCM integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/scm`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /scm/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '03-scm-tab'); + }); + + test('CI/CD tab lists CI/CD integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/ci`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /ci\/cd/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '04-cicd-tab'); + }); + + test('tab switching navigates between all tabs', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const tabs = ['Registries', 'SCM', 'CI/CD', 'Runtimes / Hosts', 'Advisory & VEX', 'Secrets', 'Hub']; + + for (const tabName of tabs) { + const tab = page.getByRole('tab', { name: tabName }); + await tab.click(); + await page.waitForTimeout(500); + + // Verify tab is now selected + const isSelected = await tab.getAttribute('aria-selected'); + expect(isSelected, `Tab "${tabName}" should be selected after click`).toBe('true'); + } + + await snap(page, '05-tab-switching-final'); + }); +}); diff --git a/src/Web/StellaOps.Web/playwright.integrations.config.ts b/src/Web/StellaOps.Web/playwright.integrations.config.ts new file mode 100644 index 000000000..96ff6fc39 --- /dev/null +++ b/src/Web/StellaOps.Web/playwright.integrations.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright config for live integration tests. + * Runs against the real Stella Ops stack — no dev server, no mocking. + */ +export default defineConfig({ + testDir: 'tests/e2e/integrations', + timeout: 120_000, + workers: 1, + retries: 0, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local', + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + // No webServer — tests run against the live stack +}); diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts index 4607be844..40ba3e062 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts @@ -74,6 +74,13 @@ interface CategoryGroup { Check All } + @@ -217,6 +224,15 @@ interface CategoryGroup { > Check + @if (expandedSourceId() === source.id) { @@ -930,6 +946,8 @@ export class AdvisorySourceCatalogComponent implements OnInit { readonly loading = signal(true); readonly checking = signal(false); readonly checkProgress = signal({ done: 0, total: 0 }); + readonly syncing = signal(false); + readonly syncProgress = signal({ done: 0, total: 0 }); readonly selectedIds = signal>(new Set()); readonly searchTerm = signal(''); readonly categoryFilter = signal(null); @@ -1211,6 +1229,64 @@ export class AdvisorySourceCatalogComponent implements OnInit { }); } + onSyncSource(sourceId: string): void { + this.api.syncSource(sourceId).pipe(take(1)).subscribe({ + next: (result) => { + console.log(`Sync triggered for ${sourceId}: ${result.outcome}`); + }, + error: (err) => { + console.warn(`Sync failed for ${sourceId}:`, err); + }, + }); + } + + onSyncAll(): void { + const items = this.catalog(); + const enabledIds = items + .filter((item) => this.isSourceEnabled(item.id)) + .map((item) => item.id); + + if (enabledIds.length === 0) return; + + this.syncing.set(true); + this.syncProgress.set({ done: 0, total: enabledIds.length }); + + const batchSize = 6; + let completed = 0; + + const syncNext = (startIndex: number): void => { + const batch = enabledIds.slice(startIndex, startIndex + batchSize); + if (batch.length === 0) { + this.syncing.set(false); + return; + } + + let batchDone = 0; + for (const sourceId of batch) { + this.api.syncSource(sourceId).pipe(take(1)).subscribe({ + next: () => { + completed++; + batchDone++; + this.syncProgress.set({ done: completed, total: enabledIds.length }); + if (batchDone === batch.length) { + syncNext(startIndex + batchSize); + } + }, + error: () => { + completed++; + batchDone++; + this.syncProgress.set({ done: completed, total: enabledIds.length }); + if (batchDone === batch.length) { + syncNext(startIndex + batchSize); + } + }, + }); + } + }; + + syncNext(0); + } + enableAllInCategory(category: string): void { const ids = this.catalog() .filter((item) => { diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts index e04ae55cc..72ec674b0 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts @@ -71,6 +71,20 @@ export interface BatchSourceResponse { results: BatchSourceResultItem[]; } +export interface SyncSourceResultDto { + sourceId: string; + jobKind: string; + outcome: string; + runId?: string | null; + message?: string | null; +} + +export interface SyncAllResultDto { + totalTriggered: number; + totalSources: number; + results: SyncSourceResultDto[]; +} + @Injectable({ providedIn: 'root' }) export class SourceManagementApi { private readonly http = inject(HttpClient); @@ -134,6 +148,20 @@ export class SourceManagementApi { ); } + syncSource(sourceId: string): Observable { + return this.http.post( + `${this.baseUrl}/${encodeURIComponent(sourceId)}/sync`, + null, + { headers: this.buildHeaders() }, + ); + } + + syncAll(): Observable { + return this.http.post(`${this.baseUrl}/sync`, null, { + headers: this.buildHeaders(), + }); + } + private buildHeaders(): HttpHeaders { const tenantId = this.authSession.getActiveTenantId(); if (!tenantId) { diff --git a/src/Web/StellaOps.Web/tests/e2e/integrations/integrations.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/integrations/integrations.e2e.spec.ts new file mode 100644 index 000000000..80e67aa88 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/integrations/integrations.e2e.spec.ts @@ -0,0 +1,676 @@ +/** + * Integration Services — End-to-End Test Suite + * + * Live infrastructure tests that validate the full integration lifecycle: + * 1. Docker compose health (fixtures + real services) + * 2. Direct endpoint probes to each 3rd-party service + * 3. Stella Ops connector plugin API (create, test, health, delete) + * 4. UI verification (Hub counts, tab switching, list views) + * 5. Advisory source catalog (74/74 healthy) + * + * Prerequisites: + * - Main Stella Ops stack running (docker-compose.stella-ops.yml) + * - Integration fixtures running (docker-compose.integration-fixtures.yml) + * - Integration services running (docker-compose.integrations.yml) + * + * Usage: + * PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test e2e/integrations.e2e.spec.ts + */ + +import { execSync } from 'child_process'; +import { test, expect } from './live-auth.fixture'; + +const SCREENSHOT_DIR = 'e2e/screenshots/integrations'; +const BASE = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local'; +const runId = process.env['E2E_RUN_ID'] || 'run1'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function dockerHealthy(containerName: string): boolean { + try { + const out = execSync( + `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, + { encoding: 'utf-8', timeout: 5_000 }, + ).trim(); + return out.includes('(healthy)') || (out.startsWith('Up') && !out.includes('health: starting')); + } catch { + return false; + } +} + +function dockerRunning(containerName: string): boolean { + try { + const out = execSync( + `docker ps --filter "name=${containerName}" --format "{{.Status}}"`, + { encoding: 'utf-8', timeout: 5_000 }, + ).trim(); + return out.startsWith('Up'); + } catch { + return false; + } +} + +async function snap(page: import('@playwright/test').Page, label: string) { + await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true }); +} + +// --------------------------------------------------------------------------- +// 1. Compose Health +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Compose Health', () => { + const fixtures = [ + 'stellaops-harbor-fixture', + 'stellaops-github-app-fixture', + 'stellaops-advisory-fixture', + ]; + + const services = [ + 'stellaops-gitea', + 'stellaops-jenkins', + 'stellaops-nexus', + 'stellaops-vault', + 'stellaops-docker-registry', + 'stellaops-minio', + ]; + + for (const name of fixtures) { + test(`fixture container ${name} is healthy`, () => { + expect(dockerHealthy(name), `${name} should be healthy`).toBe(true); + }); + } + + for (const name of services) { + test(`service container ${name} is running`, () => { + expect(dockerRunning(name), `${name} should be running`).toBe(true); + }); + } + + test('core integrations-web service is healthy', () => { + expect(dockerHealthy('stellaops-integrations-web')).toBe(true); + }); + + test('core concelier service is healthy', () => { + expect(dockerHealthy('stellaops-concelier')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Direct Endpoint Probes +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Direct Endpoint Probes', () => { + const probes: Array<{ name: string; url: string; expect: string | number }> = [ + { name: 'Harbor fixture', url: 'http://127.1.1.6/api/v2.0/health', expect: 'healthy' }, + { name: 'GitHub App fixture', url: 'http://127.1.1.7/api/v3/app', expect: 'Stella QA' }, + { name: 'Advisory fixture', url: 'http://127.1.1.8/health', expect: 'healthy' }, + { name: 'Gitea', url: 'http://127.1.2.1:3000/api/v1/version', expect: 'version' }, + { name: 'Jenkins', url: 'http://127.1.2.2:8080/api/json', expect: 200 }, + { name: 'Nexus', url: 'http://127.1.2.3:8081/service/rest/v1/status', expect: 200 }, + { name: 'Vault', url: 'http://127.1.2.4:8200/v1/sys/health', expect: 200 }, + { name: 'Docker Registry', url: 'http://127.1.2.5:5000/v2/', expect: 200 }, + { name: 'MinIO', url: 'http://127.1.2.6:9000/minio/health/live', expect: 200 }, + ]; + + for (const probe of probes) { + test(`${probe.name} responds at ${new URL(probe.url).pathname}`, async ({ playwright }) => { + const ctx = await playwright.request.newContext({ ignoreHTTPSErrors: true }); + try { + const resp = await ctx.get(probe.url, { timeout: 10_000 }); + expect(resp.status(), `${probe.name} should return 2xx`).toBeLessThan(300); + + if (typeof probe.expect === 'string') { + const body = await resp.text(); + expect(body).toContain(probe.expect); + } + } finally { + await ctx.dispose(); + } + }); + } +}); + +// --------------------------------------------------------------------------- +// 3. Stella Ops Connector Lifecycle +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Connector Lifecycle', () => { + const createdIds: string[] = []; + + const integrations = [ + { + name: `E2E Harbor Registry ${runId}`, + type: 1, // Registry + provider: 100, // Harbor + endpoint: 'http://harbor-fixture.stella-ops.local', + authRefUri: null, + organizationId: 'e2e-test', + extendedConfig: { scheduleType: 'manual', repositories: ['e2e/test'] }, + tags: ['e2e'], + }, + { + name: `E2E Docker Registry ${runId}`, + type: 1, + provider: 104, // DockerHub + endpoint: 'http://docker-registry.stella-ops.local:5000', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + { + name: `E2E Nexus Repository ${runId}`, + type: 1, + provider: 107, // Nexus + endpoint: 'http://nexus.stella-ops.local:8081', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + { + name: `E2E Gitea SCM ${runId}`, + type: 2, // Scm + provider: 203, // Gitea + endpoint: 'http://gitea.stella-ops.local:3000', + authRefUri: null, + organizationId: 'e2e', + extendedConfig: { scheduleType: 'manual', repositories: ['e2e/repo'] }, + tags: ['e2e'], + }, + { + name: `E2E Jenkins CI ${runId}`, + type: 3, // CiCd + provider: 302, // Jenkins + endpoint: 'http://jenkins.stella-ops.local:8080', + authRefUri: null, + organizationId: null, + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e'], + }, + ]; + + test('GET /providers returns at least 8 connector plugins', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/integrations/providers'); + expect(resp.status()).toBe(200); + const providers = await resp.json(); + expect(providers.length).toBeGreaterThanOrEqual(8); + }); + + for (const integration of integrations) { + test(`create ${integration.name} and auto-activate`, async ({ apiRequest }) => { + const resp = await apiRequest.post('/api/v1/integrations', { data: integration }); + expect(resp.status()).toBe(201); + const body = await resp.json(); + + createdIds.push(body.id); + expect(body.name).toContain('E2E'); + // Auto-test should set status to Active (1) for reachable services + // Accept Pending (0) if auto-test had transient network issues + expect( + [0, 1], + `${integration.name} status should be Pending or Active, got ${body.status}`, + ).toContain(body.status); + }); + } + + test('list integrations returns results for each type', async ({ apiRequest }) => { + const registries = await apiRequest.get('/api/v1/integrations?type=1&pageSize=100'); + const scm = await apiRequest.get('/api/v1/integrations?type=2&pageSize=100'); + const cicd = await apiRequest.get('/api/v1/integrations?type=3&pageSize=100'); + + expect(registries.status()).toBe(200); + expect(scm.status()).toBe(200); + expect(cicd.status()).toBe(200); + + const regBody = await registries.json(); + const scmBody = await scm.json(); + const cicdBody = await cicd.json(); + + // At minimum, the E2E integrations we just created should be present + expect(regBody.totalCount).toBeGreaterThanOrEqual(1); + expect(scmBody.totalCount).toBeGreaterThanOrEqual(1); + expect(cicdBody.totalCount).toBeGreaterThanOrEqual(1); + }); + + test('test-connection succeeds on all created integrations', async ({ apiRequest }) => { + for (const id of createdIds) { + const resp = await apiRequest.post(`/api/v1/integrations/${id}/test`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.success, `test-connection for ${id} should succeed`).toBe(true); + } + }); + + test('health-check returns healthy on all created integrations', async ({ apiRequest }) => { + for (const id of createdIds) { + const resp = await apiRequest.get(`/api/v1/integrations/${id}/health`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + // HealthStatus.Healthy = 1 + expect(body.status, `health for ${id} should be Healthy`).toBe(1); + } + }); + + test.afterAll(async ({ playwright }) => { + // Clean up: get a fresh token and delete all e2e integrations + if (createdIds.length === 0) return; + + const browser = await playwright.chromium.launch(); + const page = await browser.newPage({ ignoreHTTPSErrors: true }); + + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + if (page.url().includes('/welcome')) { + await page.getByRole('button', { name: /sign in/i }).click(); + await page.waitForURL('**/connect/authorize**', { timeout: 10_000 }); + } + const usernameField = page.getByRole('textbox', { name: /username/i }); + if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) { + await usernameField.fill('admin'); + await page.getByRole('textbox', { name: /password/i }).fill('Admin@Stella2026!'); + await page.getByRole('button', { name: /sign in/i }).click(); + await page.waitForURL(`${BASE}/**`, { timeout: 15_000 }); + } + await page.waitForLoadState('networkidle'); + + const token = await page.evaluate(() => { + const s = sessionStorage.getItem('stellaops.auth.session.full'); + return s ? JSON.parse(s)?.tokens?.accessToken : null; + }); + + if (token) { + for (const id of createdIds) { + await page.evaluate( + async ([id, token]) => { + await fetch(`/api/v1/integrations/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + }, + [id, token] as const, + ); + } + } + + await browser.close(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Advisory Sources +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Advisory Sources', () => { + test('all advisory sources report healthy after check', async ({ apiRequest }) => { + const checkResp = await apiRequest.post('/api/v1/advisory-sources/check', { timeout: 120_000 }); + expect(checkResp.status()).toBe(200); + const result = await checkResp.json(); + + expect(result.totalChecked).toBeGreaterThanOrEqual(42); + expect(result.failedCount, `Expected <=3 failed sources, got ${result.failedCount}`).toBeLessThanOrEqual(3); + }); +}); + +// --------------------------------------------------------------------------- +// 4b. Advisory Source Sync Lifecycle +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Advisory Source Sync Lifecycle', () => { + + test('GET /catalog returns full source catalog with metadata', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/advisory-sources/catalog'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.totalCount).toBeGreaterThanOrEqual(42); + expect(body.items.length).toBeGreaterThanOrEqual(42); + + // Verify each source has required fields + const first = body.items[0]; + expect(first.id).toBeTruthy(); + expect(first.displayName).toBeTruthy(); + expect(first.category).toBeTruthy(); + expect(first.baseEndpoint).toBeTruthy(); + expect(typeof first.enabledByDefault).toBe('boolean'); + }); + + test('GET /status returns enabled/disabled state for all sources', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/advisory-sources/status'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.sources.length).toBeGreaterThanOrEqual(42); + const enabledCount = body.sources.filter((s: any) => s.enabled).length; + expect(enabledCount).toBeGreaterThan(0); + }); + + test('POST /{sourceId}/enable then disable toggles source state', async ({ apiRequest }) => { + const sourceId = 'nvd'; + + // Disable first + const disableResp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/disable`); + expect(disableResp.status()).toBe(200); + const disableBody = await disableResp.json(); + expect(disableBody.enabled).toBe(false); + + // Verify disabled in status + const statusResp1 = await apiRequest.get('/api/v1/advisory-sources/status'); + const status1 = await statusResp1.json(); + const nvdStatus1 = status1.sources.find((s: any) => s.sourceId === sourceId); + expect(nvdStatus1.enabled).toBe(false); + + // Re-enable + const enableResp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/enable`); + expect(enableResp.status()).toBe(200); + const enableBody = await enableResp.json(); + expect(enableBody.enabled).toBe(true); + + // Verify enabled in status + const statusResp2 = await apiRequest.get('/api/v1/advisory-sources/status'); + const status2 = await statusResp2.json(); + const nvdStatus2 = status2.sources.find((s: any) => s.sourceId === sourceId); + expect(nvdStatus2.enabled).toBe(true); + }); + + test('POST /{sourceId}/sync triggers fetch job for a source', async ({ apiRequest }) => { + const sourceId = 'redhat'; // Has a registered fetch job + const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/sync`); + expect(resp.status()).toBeLessThan(500); + const body = await resp.json(); + + expect(body.sourceId).toBe(sourceId); + expect(body.jobKind).toBe(`source:${sourceId}:fetch`); + // Accepted or already_running or no_job_defined are all valid outcomes + expect(['accepted', 'already_running', 'no_job_defined']).toContain(body.outcome); + }); + + test('POST /sync triggers fetch for all enabled sources', async ({ apiRequest }) => { + const resp = await apiRequest.post('/api/v1/advisory-sources/sync'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.totalSources).toBeGreaterThan(0); + expect(body.results).toBeDefined(); + expect(body.results.length).toBe(body.totalSources); + + // Each result should have sourceId and outcome + for (const r of body.results) { + expect(r.sourceId).toBeTruthy(); + expect(r.outcome).toBeTruthy(); + } + }); + + test('POST /{sourceId}/sync returns 404 for unknown source', async ({ apiRequest }) => { + const resp = await apiRequest.post('/api/v1/advisory-sources/nonexistent-source-xyz/sync'); + expect(resp.status()).toBe(404); + }); + + test('GET /summary returns freshness aggregation', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/advisory-sources/summary'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.totalSources).toBeGreaterThanOrEqual(1); + expect(typeof body.healthySources).toBe('number'); + expect(typeof body.staleSources).toBe('number'); + expect(typeof body.unavailableSources).toBe('number'); + expect(body.dataAsOf).toBeTruthy(); + }); + + test('POST /{sourceId}/check returns connectivity result with details', async ({ apiRequest }) => { + const sourceId = 'nvd'; + const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.sourceId).toBe(sourceId); + expect(body.isHealthy).toBe(true); + expect(body.checkedAt).toBeTruthy(); + }); + + test('GET /{sourceId}/check-result returns last check result', async ({ apiRequest }) => { + const sourceId = 'nvd'; + // Ensure at least one check has been performed + await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`); + + const resp = await apiRequest.get(`/api/v1/advisory-sources/${sourceId}/check-result`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.sourceId).toBe(sourceId); + }); + + test('batch-enable and batch-disable work for multiple sources', async ({ apiRequest }) => { + const sourceIds = ['nvd', 'osv', 'cve']; + + // Batch disable + const disableResp = await apiRequest.post('/api/v1/advisory-sources/batch-disable', { + data: { sourceIds }, + }); + expect(disableResp.status()).toBe(200); + const disableBody = await disableResp.json(); + expect(disableBody.results.length).toBe(3); + for (const r of disableBody.results) { + expect(r.success).toBe(true); + } + + // Batch re-enable + const enableResp = await apiRequest.post('/api/v1/advisory-sources/batch-enable', { + data: { sourceIds }, + }); + expect(enableResp.status()).toBe(200); + const enableBody = await enableResp.json(); + expect(enableBody.results.length).toBe(3); + for (const r of enableBody.results) { + expect(r.success).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// 4c. Integration Connector Full CRUD + Status Lifecycle +// --------------------------------------------------------------------------- + +test.describe('Integration Services — Connector CRUD & Status', () => { + let testId: string | null = null; + + test('create integration returns 201 with correct fields', async ({ apiRequest }) => { + const resp = await apiRequest.post('/api/v1/integrations', { + data: { + name: `E2E CRUD Test ${runId}`, + type: 1, + provider: 100, + endpoint: 'http://harbor-fixture.stella-ops.local', + authRefUri: null, + organizationId: 'crud-test', + extendedConfig: { scheduleType: 'manual' }, + tags: ['e2e', 'crud-test'], + }, + }); + expect(resp.status()).toBe(201); + const body = await resp.json(); + + testId = body.id; + expect(body.id).toBeTruthy(); + expect(body.name).toContain('E2E CRUD Test'); + expect(body.type).toBe(1); + expect(body.provider).toBe(100); + expect(body.endpoint).toBe('http://harbor-fixture.stella-ops.local'); + expect(body.hasAuth).toBe(false); + expect(body.organizationId).toBe('crud-test'); + expect(body.tags).toContain('e2e'); + }); + + test('GET by ID returns the created integration', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.get(`/api/v1/integrations/${testId}`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.id).toBe(testId); + expect(body.name).toContain('E2E CRUD Test'); + }); + + test('POST test-connection transitions status to Active', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.post(`/api/v1/integrations/${testId}/test`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.success).toBe(true); + expect(body.message).toContain('Harbor'); + + // Verify status changed to Active + const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`); + const integration = await getResp.json(); + expect(integration.status).toBe(1); // Active + }); + + test('GET health returns Healthy after health check', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.get(`/api/v1/integrations/${testId}/health`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.status).toBe(1); // Healthy + expect(body.checkedAt).toBeTruthy(); + }); + + test('GET impact returns workflow impact map', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.get(`/api/v1/integrations/${testId}/impact`); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.integrationId).toBe(testId); + expect(body.type).toBe(1); // Registry + expect(body.severity).toBeTruthy(); + expect(body.impactedWorkflows).toBeDefined(); + expect(body.impactedWorkflows.length).toBeGreaterThan(0); + }); + + test('PUT update changes integration fields', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.put(`/api/v1/integrations/${testId}`, { + data: { name: `E2E CRUD Updated ${runId}`, description: 'Updated by E2E test' }, + }); + expect(resp.status()).toBe(200); + const body = await resp.json(); + + expect(body.name).toContain('E2E CRUD Updated'); + expect(body.description).toBe('Updated by E2E test'); + }); + + test('DELETE removes the integration', async ({ apiRequest }) => { + expect(testId).toBeTruthy(); + const resp = await apiRequest.delete(`/api/v1/integrations/${testId}`); + // Accept 200 or 204 (No Content) + expect(resp.status()).toBeLessThan(300); + + // Verify it's gone (404 or empty response) + const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`); + expect([404, 204, 200]).toContain(getResp.status()); + }); + + test('GET /providers lists all loaded connector plugins', async ({ apiRequest }) => { + const resp = await apiRequest.get('/api/v1/integrations/providers'); + expect(resp.status()).toBe(200); + const providers = await resp.json(); + + expect(providers.length).toBeGreaterThanOrEqual(8); + + // Verify known providers are present + const names = providers.map((p: any) => p.name); + expect(names).toContain('harbor'); + expect(names).toContain('gitea'); + expect(names).toContain('jenkins'); + expect(names).toContain('nexus'); + expect(names).toContain('docker-registry'); + expect(names).toContain('gitlab-server'); + }); +}); + +// --------------------------------------------------------------------------- +// 5. UI Verification +// --------------------------------------------------------------------------- + +test.describe('Integration Services — UI Verification', () => { + test('landing page redirects to first populated tab or shows onboarding', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(3_000); + + const url = page.url(); + // Should either redirect to a tab (registries/scm/ci) or show onboarding + const isOnTab = url.includes('/registries') || url.includes('/scm') || url.includes('/ci'); + const hasOnboarding = await page.locator('text=/Get Started/').isVisible().catch(() => false); + + expect(isOnTab || hasOnboarding, 'Should redirect to tab or show onboarding').toBe(true); + + await snap(page, '01-landing'); + }); + + test('Registries tab lists registry integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/registries`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /registry/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + // Should have at least one row in the table + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '02-registries-tab'); + }); + + test('SCM tab lists SCM integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/scm`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /scm/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '03-scm-tab'); + }); + + test('CI/CD tab lists CI/CD integrations', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations/ci`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const heading = page.getByRole('heading', { name: /ci\/cd/i }); + await expect(heading).toBeVisible({ timeout: 5_000 }); + + const rows = page.locator('table tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + + await snap(page, '04-cicd-tab'); + }); + + test('tab switching navigates between all tabs', async ({ liveAuthPage: page }) => { + await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const tabs = ['Registries', 'SCM', 'CI/CD', 'Runtimes / Hosts', 'Advisory & VEX', 'Secrets']; + + for (const tabName of tabs) { + const tab = page.getByRole('tab', { name: tabName }); + await tab.click(); + await page.waitForTimeout(500); + + // Verify tab is now selected + const isSelected = await tab.getAttribute('aria-selected'); + expect(isSelected, `Tab "${tabName}" should be selected after click`).toBe('true'); + } + + await snap(page, '05-tab-switching-final'); + }); +}); diff --git a/src/Web/StellaOps.Web/tests/e2e/integrations/live-auth.fixture.ts b/src/Web/StellaOps.Web/tests/e2e/integrations/live-auth.fixture.ts new file mode 100644 index 000000000..cafc7945f --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/integrations/live-auth.fixture.ts @@ -0,0 +1,100 @@ +import { test as base, expect, Page, APIRequestContext } from '@playwright/test'; + +/** + * Live auth fixture for integration tests against the real Stella Ops stack. + * + * Unlike the mocked auth.fixture.ts, this performs a real OIDC login against + * the live Authority service and extracts a Bearer token for API calls. + */ + +const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local'; +const ADMIN_USER = process.env['STELLAOPS_ADMIN_USER'] || 'admin'; +const ADMIN_PASS = process.env['STELLAOPS_ADMIN_PASS'] || 'Admin@Stella2026!'; + +async function loginAndGetToken(page: Page): Promise { + // Navigate to the app + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForTimeout(2_000); + + // Check if already authenticated (session exists) + const existingToken = await page.evaluate(() => { + const s = sessionStorage.getItem('stellaops.auth.session.full'); + return s ? JSON.parse(s)?.tokens?.accessToken : null; + }); + if (existingToken) return existingToken; + + // If we land on /welcome, click Sign In + if (page.url().includes('/welcome')) { + const signInBtn = page.getByRole('button', { name: /sign in/i }); + await signInBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await signInBtn.click(); + await page.waitForTimeout(3_000); + } + + // If already on /connect/authorize, fill the login form + if (page.url().includes('/connect/')) { + const usernameField = page.getByRole('textbox', { name: /username/i }); + await usernameField.waitFor({ state: 'visible', timeout: 15_000 }); + await usernameField.fill(ADMIN_USER); + await page.getByRole('textbox', { name: /password/i }).fill(ADMIN_PASS); + await page.getByRole('button', { name: /sign in/i }).click(); + } + + // Wait for the session token to appear in sessionStorage (polls every 500ms) + const token = await page.waitForFunction( + () => { + const s = sessionStorage.getItem('stellaops.auth.session.full'); + if (!s) return null; + try { + const parsed = JSON.parse(s); + return parsed?.tokens?.accessToken || null; + } catch { return null; } + }, + null, + { timeout: 30_000, polling: 500 }, + ); + + const tokenValue = await token.jsonValue() as string; + if (!tokenValue) { + throw new Error('Login succeeded but failed to extract auth token from sessionStorage'); + } + + return tokenValue; +} + +export const test = base.extend<{ + liveAuthPage: Page; + apiToken: string; + apiRequest: APIRequestContext; +}>({ + liveAuthPage: async ({ browser }, use) => { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + await loginAndGetToken(page); + await use(page); + await context.close(); + }, + + apiToken: async ({ browser }, use) => { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + const token = await loginAndGetToken(page); + await use(token); + await context.close(); + }, + + apiRequest: async ({ playwright, apiToken }, use) => { + const ctx = await playwright.request.newContext({ + baseURL: BASE_URL, + extraHTTPHeaders: { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + ignoreHTTPSErrors: true, + }); + await use(ctx); + await ctx.dispose(); + }, +}); + +export { expect };