From bd78523564bb268967f604801b431d72ffbb1103 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 14 Mar 2026 03:11:45 +0200 Subject: [PATCH] Widen scratch iteration 011 with fixture-backed integrations QA --- NOTICE.md | 5 + devops/compose/README.md | 29 + .../docker-compose.integration-fixtures.yml | 59 ++ .../github-app/default.conf | 19 + .../integration-fixtures/harbor/default.conf | 15 + devops/compose/hosts.stellaops.local | 2 + docs/INSTALL_GUIDE.md | 5 + docs/dev/DEV_ENVIRONMENT_SETUP.md | 6 +- ...h_iteration_011_full_route_action_audit.md | 77 ++ ...tform_integration_success_path_fixtures.md | 80 ++ docs/legal/THIRD-PARTY-DEPENDENCIES.md | 3 +- docs/modules/integrations/architecture.md | 5 + scripts/run-clean-scratch-iterations.ps1 | 17 +- scripts/setup.ps1 | 114 ++- scripts/setup.sh | 112 ++- .../IntegrationEndpoints.cs | 35 +- .../IntegrationService.cs | 47 +- .../GitHubAppConnectorPlugin.cs | 24 +- .../GitHubAppConnectorPluginTests.cs | 184 +++++ ...StellaOps.Integrations.Plugin.Tests.csproj | 1 + .../IntegrationImpactEndpointsTests.cs | 72 +- .../IntegrationServiceTests.cs | 226 +++--- .../scripts/live-full-core-audit.mjs | 10 + ...egrations-onboarding-persistence-check.mjs | 362 +++++++++ ...ions-onboarding-success-fixtures-check.mjs | 491 +++++++++++++ .../integration-detail.component.spec.ts | 240 ++---- .../integration-detail.component.ts | 71 +- .../integration-hub.component.ts | 5 +- .../integration-list.component.spec.ts | 206 +++--- .../integration-list.component.ts | 45 +- .../integration-hub/integration.models.ts | 355 +++++---- .../integration.service.spec.ts | 237 ++---- .../integration-hub/integration.service.ts | 71 +- .../integration-wizard.component.html | 378 +++++----- .../integration-wizard.component.spec.ts | 302 ++------ .../integration-wizard.component.ts | 694 ++++++++---------- .../integrations-hub.component.ts | 388 ++++++---- .../integrations/models/integration.models.ts | 362 ++++----- src/Web/StellaOps.Web/src/test-setup.ts | 89 ++- ...ration-onboarding-wizard.component.spec.ts | 208 +++--- 40 files changed, 3478 insertions(+), 2173 deletions(-) create mode 100644 devops/compose/docker-compose.integration-fixtures.yml create mode 100644 devops/compose/fixtures/integration-fixtures/github-app/default.conf create mode 100644 devops/compose/fixtures/integration-fixtures/harbor/default.conf create mode 100644 docs/implplan/SPRINT_20260313_006_Platform_scratch_iteration_011_full_route_action_audit.md create mode 100644 docs/implplan/SPRINT_20260314_001_Platform_integration_success_path_fixtures.md create mode 100644 src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/GitHubAppConnectorPluginTests.cs create mode 100644 src/Web/StellaOps.Web/scripts/live-integrations-onboarding-persistence-check.mjs create mode 100644 src/Web/StellaOps.Web/scripts/live-integrations-onboarding-success-fixtures-check.mjs diff --git a/NOTICE.md b/NOTICE.md index 8fca55557..cbb17ddf4 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -181,6 +181,11 @@ required notices or source offers. - **License:** Apache-2.0 - **Source:** https://github.com/moby/moby +#### NGINX +- **License:** BSD-2-Clause +- **Source:** https://nginx.org/ +- **Usage:** Optional local QA integration fixtures for retained Harbor and GitHub App onboarding checks + #### Kubernetes - **License:** Apache-2.0 - **Source:** https://github.com/kubernetes/kubernetes diff --git a/devops/compose/README.md b/devops/compose/README.md index 4079ddf8b..a29f7a5a5 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -8,6 +8,7 @@ Consolidated Docker Compose configuration for the StellaOps platform. All profil |--------------|---------| | Run the full platform | `docker compose -f docker-compose.stella-ops.yml up -d` | | Add observability | `docker compose -f docker-compose.stella-ops.yml -f docker-compose.telemetry.yml up -d` | +| Start QA integration fixtures | `docker compose -f docker-compose.integration-fixtures.yml up -d` | | Run CI/testing infrastructure | `docker compose -f docker-compose.testing.yml --profile ci up -d` | | Deploy with China compliance | See [China Compliance](#china-compliance-sm2sm3sm4) | | Deploy with Russia compliance | See [Russia Compliance](#russia-compliance-gost) | @@ -22,6 +23,7 @@ Consolidated Docker Compose configuration for the StellaOps platform. All profil | File | Purpose | |------|---------| | `docker-compose.stella-ops.yml` | **Main stack**: PostgreSQL 18.1, Valkey 9.0.1, RustFS, Rekor v2, all StellaOps services | +| `docker-compose.integration-fixtures.yml` | **QA success fixtures**: Harbor and GitHub App API fixtures for retained onboarding verification | | `docker-compose.telemetry.yml` | **Observability**: OpenTelemetry collector, Prometheus, Tempo, Loki | | `docker-compose.testing.yml` | **CI/Testing**: Test databases, mock services, Gitea for integration tests | | `docker-compose.dev.yml` | **Minimal dev infrastructure**: PostgreSQL, Valkey, RustFS only | @@ -203,6 +205,33 @@ docker compose -f docker-compose.testing.yml --profile all up -d --- +### QA Integration Success Fixtures + +Use the fixture compose lane when you need a scratch stack to prove successful Integrations Hub onboarding for the providers currently exposed in the local UI (`Harbor`, `GitHub App`). + +```bash +# Start the fixture services after the main stack is up +docker compose -f docker-compose.integration-fixtures.yml up -d + +# Harbor success-path endpoint +curl http://harbor-fixture.stella-ops.local/api/v2.0/health + +# GitHub App success-path endpoints +curl http://github-app-fixture.stella-ops.local/api/v3/app +curl http://github-app-fixture.stella-ops.local/api/v3/rate_limit +``` + +Fixture endpoints: + +| Service | Hostname | Port | Contract | +|---------|----------|------|----------| +| Harbor fixture | `harbor-fixture.stella-ops.local` | 80 | `GET /api/v2.0/health` -> healthy JSON + `X-Harbor-Version` | +| GitHub App fixture | `github-app-fixture.stella-ops.local` | 80 | `GET /api/v3/app`, `GET /api/v3/rate_limit` | + +These fixtures are deterministic QA aids only; they are not production dependencies and remain opt-in. + +--- + ## Regional Compliance Deployments ### China Compliance (SM2/SM3/SM4) diff --git a/devops/compose/docker-compose.integration-fixtures.yml b/devops/compose/docker-compose.integration-fixtures.yml new file mode 100644 index 000000000..2d970c2ff --- /dev/null +++ b/devops/compose/docker-compose.integration-fixtures.yml @@ -0,0 +1,59 @@ +# ============================================================================= +# STELLA OPS - QA INTEGRATION FIXTURES +# ============================================================================= +# Deterministic external-service fixtures used to prove successful UI onboarding +# for the providers currently exposed in the local Integrations Hub. +# +# Usage: +# docker compose -f devops/compose/docker-compose.integration-fixtures.yml up -d +# ============================================================================= + +networks: + stellaops: + external: true + name: stellaops + +services: + harbor-fixture: + image: nginx:1.27-alpine + container_name: stellaops-harbor-fixture + restart: unless-stopped + ports: + - "127.1.1.6:80:80" + volumes: + - ./fixtures/integration-fixtures/harbor/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + stellaops: + aliases: + - harbor-fixture.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/v2.0/health | grep -q 'healthy'"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 5s + labels: + com.stellaops.profile: "qa-fixtures" + com.stellaops.environment: "local-qa" + + github-app-fixture: + image: nginx:1.27-alpine + container_name: stellaops-github-app-fixture + restart: unless-stopped + ports: + - "127.1.1.7:80:80" + volumes: + - ./fixtures/integration-fixtures/github-app/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + stellaops: + aliases: + - github-app-fixture.stella-ops.local + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/v3/app | grep -q 'Stella QA GitHub App'"] + 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/fixtures/integration-fixtures/github-app/default.conf b/devops/compose/fixtures/integration-fixtures/github-app/default.conf new file mode 100644 index 000000000..6007664e0 --- /dev/null +++ b/devops/compose/fixtures/integration-fixtures/github-app/default.conf @@ -0,0 +1,19 @@ +server { + listen 80; + server_name github-app-fixture.stella-ops.local; + + location = /api/v3/app { + default_type application/json; + return 200 '{"id":424242,"name":"Stella QA GitHub App","slug":"stella-qa-app"}'; + } + + location = /api/v3/rate_limit { + default_type application/json; + return 200 '{"resources":{"core":{"limit":5000,"remaining":4991,"reset":1893456000}}}'; + } + + location / { + default_type text/plain; + return 200 'Stella Ops QA GitHub App fixture'; + } +} diff --git a/devops/compose/fixtures/integration-fixtures/harbor/default.conf b/devops/compose/fixtures/integration-fixtures/harbor/default.conf new file mode 100644 index 000000000..328bc799e --- /dev/null +++ b/devops/compose/fixtures/integration-fixtures/harbor/default.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name harbor-fixture.stella-ops.local; + + location = /api/v2.0/health { + default_type application/json; + add_header X-Harbor-Version 2.10.0 always; + return 200 '{"status":"healthy","components":[{"name":"core","status":"healthy"},{"name":"jobservice","status":"healthy"},{"name":"registry","status":"healthy"}]}'; + } + + location / { + default_type text/plain; + return 200 'Stella Ops QA Harbor fixture'; + } +} diff --git a/devops/compose/hosts.stellaops.local b/devops/compose/hosts.stellaops.local index 0f2d6cb93..d56a77b2e 100644 --- a/devops/compose/hosts.stellaops.local +++ b/devops/compose/hosts.stellaops.local @@ -59,3 +59,5 @@ 127.1.1.3 s3.stella-ops.local 127.1.1.4 rekor.stella-ops.local 127.1.1.5 registry.stella-ops.local +127.1.1.6 harbor-fixture.stella-ops.local +127.1.1.7 github-app-fixture.stella-ops.local diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index b80f7f32e..e708d837b 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -40,6 +40,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config ```powershell .\scripts\setup.ps1 # full setup .\scripts\setup.ps1 -InfraOnly # infrastructure only (PostgreSQL, Valkey, RustFS, Rekor, Zot) +.\scripts\setup.ps1 -QaIntegrationFixtures # full setup plus Harbor/GitHub App QA fixtures ``` **Linux / macOS:** @@ -47,6 +48,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config ```bash ./scripts/setup.sh # full setup ./scripts/setup.sh --infra-only # infrastructure only +./scripts/setup.sh --qa-integration-fixtures # full setup plus Harbor/GitHub App QA fixtures ``` The scripts will: @@ -57,6 +59,7 @@ The scripts will: 5. Create or reuse the external frontdoor Docker network from `.env` (`FRONTDOOR_NETWORK`, default `stellaops_frontdoor`) 6. Stop repo-local host-run Stella services that would lock build outputs, then build repo-owned .NET solutions and publish backend services locally into small Docker contexts before building hardened runtime images (vendored or generated trees such as `node_modules`, `dist`, `coverage`, and `output` are excluded) 7. Launch the full platform with health checks, perform one bounded restart pass for services that stay unhealthy after first boot, wait for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), then complete an authenticated convergence gate that proves topology inventory, notifications administration overrides, and promotion bootstrap flows load cleanly before reporting success +8. If `-QaIntegrationFixtures` / `--qa-integration-fixtures` is enabled, start deterministic Harbor and GitHub App fixtures and verify them so the local Integrations Hub can be exercised with successful UI onboarding Open **https://stella-ops.local** when setup completes. @@ -85,6 +88,8 @@ Stella Ops services bind to unique loopback IPs so all can use port 443 without Runtime URL convention remains `*.stella-ops.local`; `hosts.stellaops.local` is the template file name only. +The same template also carries the optional `harbor-fixture.stella-ops.local` and `github-app-fixture.stella-ops.local` aliases used by the fixture-backed integrations QA lane. + - **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator) - **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'` diff --git a/docs/dev/DEV_ENVIRONMENT_SETUP.md b/docs/dev/DEV_ENVIRONMENT_SETUP.md index 4a1580671..aa7cb2946 100644 --- a/docs/dev/DEV_ENVIRONMENT_SETUP.md +++ b/docs/dev/DEV_ENVIRONMENT_SETUP.md @@ -17,6 +17,7 @@ Setup scripts validate prerequisites, build solutions and Docker images, and lau .\scripts\setup.ps1 -SkipBuild # skip .NET builds, build images and start platform .\scripts\setup.ps1 -SkipImages # build .NET but skip Docker images .\scripts\setup.ps1 -ImagesOnly # only build Docker images +.\scripts\setup.ps1 -QaIntegrationFixtures # full setup plus Harbor/GitHub App QA fixtures for real UI onboarding checks ``` **Linux / macOS:** @@ -27,9 +28,10 @@ Setup scripts validate prerequisites, build solutions and Docker images, and lau ./scripts/setup.sh --skip-build # skip .NET builds ./scripts/setup.sh --skip-images # skip Docker image builds ./scripts/setup.sh --images-only # only build Docker images +./scripts/setup.sh --qa-integration-fixtures # full setup plus Harbor/GitHub App QA fixtures ``` -The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, waits for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), and then runs an authenticated readiness probe that proves the topology inventory, notifications administration overrides, and promotion bootstrap routes load cleanly before the script prints success. See the manual steps below for details on each stage. +The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, waits for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), and then runs an authenticated readiness probe that proves the topology inventory, notifications administration overrides, and promotion bootstrap routes load cleanly before the script prints success. When `-QaIntegrationFixtures` / `--qa-integration-fixtures` is enabled, setup also starts deterministic Harbor and GitHub App fixtures and smoke-checks them so the Integrations Hub can be verified with successful UI onboarding, not just failure-path cards. See the manual steps below for details on each stage. On Windows and Linux, the backend image builder now publishes each selected .NET service locally and builds the hardened runtime image from a small temporary context. That avoids repeatedly streaming the whole monorepo into Docker during scratch setup. @@ -121,7 +123,7 @@ Full details: [`docs/technical/architecture/port-registry.md`](../technical/arch ### Automated (recommended) -The setup scripts (`scripts/setup.ps1` / `scripts/setup.sh`) will detect missing entries and offer to install them automatically. +The setup scripts (`scripts/setup.ps1` / `scripts/setup.sh`) will detect missing entries and offer to install them automatically. The host template now also includes `harbor-fixture.stella-ops.local` and `github-app-fixture.stella-ops.local` for the optional fixture-backed integrations QA lane. ### Manual diff --git a/docs/implplan/SPRINT_20260313_006_Platform_scratch_iteration_011_full_route_action_audit.md b/docs/implplan/SPRINT_20260313_006_Platform_scratch_iteration_011_full_route_action_audit.md new file mode 100644 index 000000000..2c5e69bf1 --- /dev/null +++ b/docs/implplan/SPRINT_20260313_006_Platform_scratch_iteration_011_full_route_action_audit.md @@ -0,0 +1,77 @@ +# Sprint 20260313_006 - Platform Scratch Iteration 011 Full Route Action Audit + +## Topic & Scope +- Wipe Stella-owned runtime state again and rerun the documented setup path from zero state. +- Re-enter the application as a first-time user after bootstrap and rerun the full route, page-load, and page-action audit with Playwright. +- Convert any newly discovered manual route, page-load, or action gap into retained Playwright coverage before the iteration is considered complete. +- Group any fresh failures by root cause before implementing fixes so the commit closes a full iteration rather than isolated page patches. +- Working directory: `.`. +- Expected evidence: wipe proof, setup convergence proof, fresh Playwright route/page/action evidence, retained scenario updates, grouped defect analysis, focused tests, and rebuilt-stack retest results. + +## Dependencies & Concurrency +- Depends on local commit `3b1b7dad8` as the closed baseline from scratch iteration 010. +- Safe parallelism: none during wipe/setup because the environment reset is global to the machine. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### PLATFORM-SCRATCH-ITER11-001 - Rebuild from zero Stella runtime state +Status: DONE +Dependency: none +Owners: QA, 3rd line support +Task description: +- Remove Stella-only containers, images, volumes, and the frontdoor network, then rerun the documented setup entrypoint from zero Stella state. + +Completion criteria: +- [x] Stella-only Docker state is removed. +- [x] `scripts/setup.ps1` is rerun from zero state. +- [x] The first setup outcome is captured before UI verification starts. + +### PLATFORM-SCRATCH-ITER11-002 - Re-run the first-user full route/page/action audit +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER11-001 +Owners: QA +Task description: +- After scratch setup converges, rerun the canonical route sweep plus the full route/page/action audit suite, including changed-surface, user-reported, and ownership checks, and enumerate every newly exposed issue before repair work begins. + +Completion criteria: +- [x] Fresh route sweep evidence is captured on the rebuilt stack. +- [x] Fresh route/page/action evidence is captured across the full aggregate suite, including changed-surface and ownership checks. +- [x] Newly exposed defects are grouped and any new manual findings are queued into retained Playwright scenarios before any fix commit is prepared. + +### PLATFORM-SCRATCH-ITER11-003 - Repair the grouped defects exposed by the fresh audit +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER11-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the fresh audit, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected verification slices plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures. +- [x] Fixes land with focused regression coverage and retained Playwright scenario updates where practical. +- [x] The rebuilt stack is retested before the iteration commit. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-13 | Sprint created immediately after local commit `3b1b7dad8` closed scratch iteration 010. | QA | +| 2026-03-14 | Rebuilt iteration 011 from a fresh Stella state and widened the audit scope beyond prior route checks by adding fixture-backed successful Harbor and GitHub App onboarding to the retained Playwright aggregate. | QA | +| 2026-03-14 | The fresh first-user audit exposed a grouped integrations root cause: GitHub App enterprise endpoints were tested with leading-slash requests that dropped the `/api/v3` base and returned non-JSON responses during `Test Connection`. | 3rd line support | +| 2026-03-14 | Fixed the connector path normalization, expanded retained Playwright for both failed-path and success-path onboarding, and reran the rebuilt-stack aggregate audit clean at `24/24` suites passed with `111/111` canonical routes still green. | Architect / Developer / QA | + +## Decisions & Risks +- Decision: the iteration remains a strict wipe -> setup -> full route/page/action audit -> grouped remediation loop; no fixes start until the fresh-stack audit defect set is collected. +- Decision: any new manual route, page, or action discovered during QA must become retained Playwright coverage before iteration 011 may close. +- Risk: scratch rebuilds remain expensive, so verification stays Playwright-first with focused backend and Angular regression slices after the browser audit identifies the grouped defect set. +- Decision: iteration 011 widened the first-user audit baseline itself rather than accepting a clean rerun; successful Harbor and GitHub App onboarding is now part of retained scratch QA instead of an ad hoc follow-up. +- Decision: the grouped defect fix stayed at the provider contract layer in the GitHub connector instead of adding UI workarounds around malformed enterprise API bases. +- Evidence: `dotnet test src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj -v minimal` passed `12/12`; `dotnet test src/Integrations/__Tests/StellaOps.Integrations.Tests/StellaOps.Integrations.Tests.csproj -v minimal` passed `57/57`; the rebuilt-stack aggregate audit passed `24/24` suites with only one runtime-only first-pass retry that stabilized cleanly. + +## Next Checkpoints +- Start scratch iteration 012 from a fresh Stella wipe with the fixture-enabled setup lane available and continue widening retained coverage only when the full first-user audit exposes a real new gap. +- Keep route/page/action discovery ahead of fixes; no narrowed page-only commit should close the next iteration unless the full defect set truly contains one grouped root cause. diff --git a/docs/implplan/SPRINT_20260314_001_Platform_integration_success_path_fixtures.md b/docs/implplan/SPRINT_20260314_001_Platform_integration_success_path_fixtures.md new file mode 100644 index 000000000..4a5addbe1 --- /dev/null +++ b/docs/implplan/SPRINT_20260314_001_Platform_integration_success_path_fixtures.md @@ -0,0 +1,80 @@ +# Sprint 20260314_001 - Platform Integration Success Path Fixtures + +## Topic & Scope +- Add deterministic local external-service fixtures for the UI-exposed integration providers so scratch setup can prove successful onboarding, not just graceful failure handling. +- Wire the fixture lane into the documented setup path as an explicit opt-in QA mode instead of relying on ad hoc manual containers. +- Extend retained Playwright coverage so Harbor and GitHub App onboarding can be verified from the real UI with successful test-connection and health outcomes. +- Working directory: `devops/compose`. +- Cross-module edits allowed for `scripts/setup.ps1`, `scripts/setup.sh`, `scripts/run-clean-scratch-iterations.ps1`, `src/Web/StellaOps.Web/scripts/**`, `docs/**`, `NOTICE.md`, and `docs/legal/THIRD-PARTY-DEPENDENCIES.md`. +- Expected evidence: compose fixture definitions, hosts/docs updates, setup wiring, retained Playwright success-path evidence, and scratch-loop adoption notes. + +## Dependencies & Concurrency +- Depends on the currently active scratch iteration proving the integrations UI/runtime path is contract-correct before fixture-based success-path work is layered on top. +- Safe parallelism: fixture compose/docs work may proceed while unrelated product slices continue, but setup script edits should be serialized. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Integrations/AGENTS.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/INSTALL_GUIDE.md` +- `devops/compose/README.md` +- `docs/modules/integrations/architecture.md` + +## Delivery Tracker + +### PLATFORM-INTEGRATION-FIXTURES-001 - Define deterministic external integration fixtures +Status: DONE +Dependency: none +Owners: Architect, Developer +Task description: +- Add lightweight deterministic fixture services for Harbor and GitHub App style APIs so the locally visible onboarding providers have a success-path target during scratch QA. + +Completion criteria: +- [x] Fixture compose file exists with deterministic Harbor and GitHub App endpoints. +- [x] Local hostnames/ports are documented and added to the compose host template. +- [x] License/notice updates are recorded for any newly introduced infrastructure image. + +### PLATFORM-INTEGRATION-FIXTURES-002 - Wire fixture mode into documented setup +Status: DONE +Dependency: PLATFORM-INTEGRATION-FIXTURES-001 +Owners: Developer, Documentation author +Task description: +- Extend setup scripts and setup docs with an explicit fixture-enabled QA mode so scratch rebuilds can include the success-path integrations lane without ad hoc manual steps. + +Completion criteria: +- [x] `setup.ps1` and `setup.sh` can start fixture services in a documented QA mode. +- [x] Scratch iteration tooling can opt into the fixture mode. +- [x] Install/dev docs explain when and how to use the fixture lane. + +### PLATFORM-INTEGRATION-FIXTURES-003 - Add retained Playwright success-path coverage +Status: DONE +Dependency: PLATFORM-INTEGRATION-FIXTURES-002 +Owners: QA, Test Automation +Task description: +- Add retained Playwright that onboards Harbor and GitHub App from the real UI against the deterministic fixtures, verifies successful test-connection/health behavior, and folds the scenario into the aggregate scratch audit. + +Completion criteria: +- [x] Retained Playwright success-path scripts exist for the fixture-backed onboarding flows. +- [x] Aggregate audit includes the new success-path suite(s). +- [x] Scratch QA evidence shows successful UI onboarding and cleanup for both providers. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-14 | Sprint created after the integrations onboarding iteration proved contract-correct UI flow but exposed a remaining QA gap: success-path external fixtures do not yet exist for the UI-exposed Harbor and GitHub App providers. | Architect / QA | +| 2026-03-14 | Added deterministic Harbor and GitHub App fixture compose services, documented their host aliases, and recorded the NGINX image license/notice updates required by the BUSL dependency gate. | Architect / Developer | +| 2026-03-14 | Wired explicit `-QaIntegrationFixtures` / `--qa-integration-fixtures` setup modes into the documented setup entrypoints and scratch runner, and hardened fixture smoke checks to use loopback bindings when the host file cannot be elevated on the local machine. | Developer / Documentation author | +| 2026-03-14 | Added retained Playwright success-path onboarding for Harbor and GitHub App, discovered the GitHub App `/api/v3` path normalization bug during real UI test-connection, fixed it at the connector layer, and reverified the full aggregate audit clean at `24/24` suites passed. | QA / 3rd line support / Developer | + +## Decisions & Risks +- Decision: fixture-backed success-path onboarding is a QA infrastructure requirement, not a product shortcut; the product still keeps real provider contracts and AuthRef behavior. +- Decision: fixture mode stays explicit and opt-in so the default local product setup remains production-shaped. +- Risk: adding third-party infrastructure images triggers the repo license gate and doc updates; this must be handled inside the same slice. +- Decision: setup host verification now checks the complete `devops/compose/hosts.stellaops.local` alias set instead of treating any `stella-ops.local` entry as sufficient; partial host-file state was masking fixture readiness gaps. +- Decision: optional fixture smoke probes use fixed loopback bindings (`127.1.1.6`, `127.1.1.7`) during setup so the documented setup path remains verifiable in a non-elevated shell even when Windows host-file writes are blocked. +- Decision: GitHub App endpoints are normalized to exactly one API root; GitHub Cloud uses `https://api.github.com/`, while GHES accepts either the appliance root or an explicit `/api/v3` base without duplicating or stripping the API prefix. +- Docs: [DEV_ENVIRONMENT_SETUP.md](/C:/dev/New%20folder/git.stella-ops.org/docs/dev/DEV_ENVIRONMENT_SETUP.md), [INSTALL_GUIDE.md](/C:/dev/New%20folder/git.stella-ops.org/docs/INSTALL_GUIDE.md), [architecture.md](/C:/dev/New%20folder/git.stella-ops.org/docs/modules/integrations/architecture.md), [README.md](/C:/dev/New%20folder/git.stella-ops.org/devops/compose/README.md) + +## Next Checkpoints +- Fold the fixture-enabled setup lane into the next zero-state scratch iteration so the widened integration discovery becomes part of the normal first-user audit baseline. +- Expand the same approach only if additional providers become UI-exposed in later iterations. diff --git a/docs/legal/THIRD-PARTY-DEPENDENCIES.md b/docs/legal/THIRD-PARTY-DEPENDENCIES.md index 5ced9c83b..1e82a2425 100644 --- a/docs/legal/THIRD-PARTY-DEPENDENCIES.md +++ b/docs/legal/THIRD-PARTY-DEPENDENCIES.md @@ -17,7 +17,7 @@ This document provides a comprehensive inventory of all third-party dependencies | NuGet (Dev/Test) | ~50+ | MIT, Apache-2.0 | | npm (Runtime) | ~15 | MIT, Apache-2.0, ISC, 0BSD | | npm (Dev) | ~30+ | MIT, Apache-2.0 | -| Infrastructure | 6 | PostgreSQL, MPL-2.0, BSD-3-Clause, Apache-2.0 | +| Infrastructure | 7 | PostgreSQL, MPL-2.0, BSD-2-Clause, BSD-3-Clause, Apache-2.0 | ### Canonical License Declarations @@ -290,6 +290,7 @@ Components required for deployment but not bundled with StellaOps source. | Valkey | ≥7.2 | BSD-3-Clause | BSD-3-Clause | Separate | Optional cache (Redis fork) for StellaOps and Rekor | | Rekor v2 (rekor-tiles) | v2 (tiles) | Apache-2.0 | Apache-2.0 | Separate | Optional transparency log (POSIX tiles backend) | | Docker | ≥24 | Apache-2.0 | Apache-2.0 | Tooling | Container runtime | +| NGINX | 1.27-alpine | BSD-2-Clause | BSD-2-Clause | Separate | Optional local QA fixture image for Harbor and GitHub App onboarding success-path checks | | OCI Registry | - | Varies | - | External | Harbor (Apache-2.0), Docker Hub, etc. | | Kubernetes | ≥1.28 | Apache-2.0 | Apache-2.0 | Orchestration | Optional | | all-MiniLM-L6-v2 embedding model | - | Apache-2.0 | Apache-2.0 | Optional runtime asset | Local semantic embedding model for AdvisoryAI (`VectorEncoderType=onnx`) | diff --git a/docs/modules/integrations/architecture.md b/docs/modules/integrations/architecture.md index 3e97d7177..29f8a162c 100644 --- a/docs/modules/integrations/architecture.md +++ b/docs/modules/integrations/architecture.md @@ -96,6 +96,11 @@ public interface IIntegrationPlugin - **Harbor** - Robot account authentication, project and repository enumeration - **InMemory** - Deterministic test double for integration tests and offline development +### Provider endpoint contracts + +- **GitHub App** - Operators provide either the GitHub Cloud root (`https://github.com`), a GitHub Enterprise Server root, or an explicit `/api/v3` base. The connector normalizes the endpoint to a single API root and probes relative `app` / `rate_limit` paths so GitHub Enterprise onboarding never falls back to origin-root `/app`. +- **Harbor** - Operators provide the Harbor base URL. Stella Ops probes the provider-specific `/api/v2.0/health` route for connection tests and health checks. + ## Security Considerations - **AuthRef URI credential model:** Credentials are stored in an external vault (e.g., HashiCorp Vault, Azure Key Vault). The integration catalog stores only the URI reference (`authref://vault/path/to/secret`), never the raw secret. diff --git a/scripts/run-clean-scratch-iterations.ps1 b/scripts/run-clean-scratch-iterations.ps1 index 6bc23bfb2..827ed968e 100644 --- a/scripts/run-clean-scratch-iterations.ps1 +++ b/scripts/run-clean-scratch-iterations.ps1 @@ -8,7 +8,9 @@ param( [string]$ImplId = (Get-Date).ToUniversalTime().ToString('yyyyMMdd'), - [int]$StartingBatchId = 0 + [int]$StartingBatchId = 0, + + [bool]$QaIntegrationFixtures = $true ) Set-StrictMode -Version Latest @@ -400,6 +402,9 @@ for ($iteration = $StartIteration; $iteration -le $EndIteration; $iteration++) { $state.Decisions.Add('Decision: each scratch iteration remains a full wipe -> setup -> route/page/action audit -> grouped remediation loop; if the audit comes back clean, that still counts as a completed iteration because the full loop was executed.') $state.Decisions.Add('Decision: changed or newly discovered user flows must be converted into retained Playwright coverage before the next scratch iteration starts so the audit surface expands instead of rediscovering the same gaps manually.') $state.Decisions.Add('Risk: scratch rebuilds remain expensive, so verification stays Playwright-first with focused test/build slices rather than indiscriminate full-solution test runs.') + if ($QaIntegrationFixtures) { + $state.Decisions.Add('Decision: scratch setup starts the Harbor and GitHub App QA fixtures so the full audit proves real success-path integration onboarding from the UI, not just graceful failure handling.') + } $state.NextCheckpoints.Add('Finish the Stella-only wipe and capture the next zero-state setup outcome.') $state.NextCheckpoints.Add('Run the full Playwright route/page/action audit, including changed-surface and ownership checks, on the rebuilt stack before diagnosing any new fixes.') Add-SprintLog -State $state -Update "Sprint created for the next scratch iteration after local commit ``$baselineCommit`` closed the previous clean baseline." -Owner 'QA' @@ -413,12 +418,18 @@ for ($iteration = $StartIteration; $iteration -le $EndIteration; $iteration++) { Add-SprintLog -State $state -Update 'Removed Stella-only containers, `stellaops/*:dev` images, Stella compose volumes, and the `stellaops` / `stellaops_frontdoor` networks to return the machine to zero Stella runtime state for the new iteration.' -Owner 'QA / 3rd line support' Write-SprintState -State $state -Path $sprintPath - Invoke-External -FilePath (Join-Path $repoRoot 'scripts/setup.ps1') -WorkingDirectory $repoRoot + $setupArguments = @() + if ($QaIntegrationFixtures) { + $setupArguments += '-QaIntegrationFixtures' + } + + Invoke-External -FilePath (Join-Path $repoRoot 'scripts/setup.ps1') -ArgumentList $setupArguments -WorkingDirectory $repoRoot $state.Criteria12 = $true $state.Criteria13 = $true $state.Status1 = 'DONE' $healthySummary = Get-HealthyContainerSummary - Add-SprintLog -State $state -Update "The zero-state setup rerun completed cleanly: ``36/36`` solution builds passed, the full image matrix rebuilt, platform services converged, and ``$healthySummary`` Stella containers are healthy on ``https://stella-ops.local``." -Owner 'QA / 3rd line support' + $fixtureModeText = if ($QaIntegrationFixtures) { ' with Harbor/GitHub App QA fixtures enabled' } else { '' } + Add-SprintLog -State $state -Update "The zero-state setup rerun completed cleanly$fixtureModeText: ``36/36`` solution builds passed, the full image matrix rebuilt, platform services converged, and ``$healthySummary`` Stella containers are healthy on ``https://stella-ops.local``." -Owner 'QA / 3rd line support' $state.Status2 = 'DOING' Write-SprintState -State $state -Path $sprintPath diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 68b5bc268..1c0b0e798 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -13,13 +13,16 @@ Only build Docker images (skip infra start and .NET build). .PARAMETER SkipImages Skip Docker image builds. +.PARAMETER QaIntegrationFixtures + Start the optional Harbor and GitHub App QA fixtures used for successful Integrations Hub onboarding checks. #> [CmdletBinding()] param( [switch]$SkipBuild, [switch]$InfraOnly, [switch]$ImagesOnly, - [switch]$SkipImages + [switch]$SkipImages, + [switch]$QaIntegrationFixtures ) $ErrorActionPreference = 'Stop' @@ -391,7 +394,7 @@ function Test-Prerequisites { # ─── 2. Check and install hosts file ───────────────────────────────────── function Test-HostsFile { - Write-Step 'Checking hosts file for stella-ops.local entries' + Write-Step 'Checking hosts file for required Stella Ops entries' $hostsPath = 'C:\Windows\System32\drivers\etc\hosts' $hostsSource = Join-Path $Root 'devops/compose/hosts.stellaops.local' @@ -400,20 +403,49 @@ function Test-HostsFile { return } - $content = Get-Content $hostsPath -Raw - if ($content -match 'stella-ops\.local') { - Write-Ok 'stella-ops.local entries found in hosts file' - return - } - - Write-Warn 'stella-ops.local entries NOT found in hosts file.' - if (-not (Test-Path $hostsSource)) { Write-Warn "Hosts source file not found at $hostsSource" Write-Host ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' -ForegroundColor Yellow return } + $content = Get-Content $hostsPath -Raw + $sourceLines = Get-Content $hostsSource | Where-Object { + $trimmed = $_.Trim() + $trimmed -and -not $trimmed.StartsWith('#') + } + + $missingLines = @() + $missingHosts = @() + foreach ($line in $sourceLines) { + $tokens = $line -split '\s+' | Where-Object { $_ } + if ($tokens.Count -lt 2) { + continue + } + + $hostnames = $tokens | Select-Object -Skip 1 + $lineMissing = $false + foreach ($hostname in $hostnames) { + $escapedHostname = [regex]::Escape($hostname) + if ($content -notmatch "(?m)(^|\s)$escapedHostname($|\s)") { + $missingHosts += $hostname + $lineMissing = $true + } + } + + if ($lineMissing) { + $missingLines += $line + } + } + + if ($missingHosts.Count -eq 0) { + Write-Ok 'All required Stella Ops host entries are present in the hosts file' + return + } + + $missingHosts = $missingHosts | Sort-Object -Unique + Write-Warn "Missing Stella Ops host aliases: $([string]::Join(', ', $missingHosts))" + # Check if running as Administrator $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) @@ -424,9 +456,9 @@ function Test-HostsFile { Write-Host '' $answer = Read-Host ' Add entries to hosts file now? (Y/n)' if ($answer -eq '' -or $answer -match '^[Yy]') { - $hostsBlock = Get-Content $hostsSource -Raw - Add-Content -Path $hostsPath -Value "`n$hostsBlock" - Write-Ok 'Hosts entries added successfully' + Add-Content -Path $hostsPath -Value '' + Add-Content -Path $hostsPath -Value ($missingLines -join [Environment]::NewLine) + Write-Ok "Added $($missingLines.Count) missing host entry line(s) successfully" } else { Write-Warn 'Skipped. Add them manually before accessing the platform.' Write-Host " Copy from: $hostsSource" -ForegroundColor Yellow @@ -597,6 +629,27 @@ function Start-Platform { -restartAfterSeconds 45) } +function Start-QaIntegrationFixtures { + Write-Step 'Starting QA integration fixtures' + Push-Location $ComposeDir + try { + docker compose -f docker-compose.integration-fixtures.yml up -d + if ($LASTEXITCODE -ne 0) { + Write-Fail 'Failed to start QA integration fixtures.' + exit 1 + } + + [void](Wait-ForComposeConvergence ` + -composeFiles @('docker-compose.integration-fixtures.yml') ` + -successMessage 'QA integration fixtures are healthy' ` + -maxWaitSeconds 90 ` + -restartAfterSeconds 30) + } + finally { + Pop-Location + } +} + function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) { for ($attempt = 1; $attempt -le $attempts; $attempt++) { $statusCode = $null @@ -770,6 +823,24 @@ function Test-Smoke { $hasBlockingFailures = $true } + if ($QaIntegrationFixtures) { + $harborFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.6/api/v2.0/health' @(200) + if ($null -ne $harborFixtureStatus) { + Write-Ok "Harbor QA fixture (HTTP $harborFixtureStatus)" + } else { + Write-Fail 'Harbor QA fixture did not respond with HTTP 200 on /api/v2.0/health' + $hasBlockingFailures = $true + } + + $githubFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.7/api/v3/app' @(200) + if ($null -ne $githubFixtureStatus) { + Write-Ok "GitHub App QA fixture (HTTP $githubFixtureStatus)" + } else { + Write-Fail 'GitHub App QA fixture did not respond with HTTP 200 on /api/v3/app' + $hasBlockingFailures = $true + } + } + if (-not $InfraOnly) { if (Test-FrontdoorBootstrap) { Write-Ok 'Frontdoor bootstrap path is ready for first-user sign-in' @@ -794,8 +865,15 @@ function Test-Smoke { @('docker-compose.stella-ops.yml') } + if ($QaIntegrationFixtures) { + $composeFiles += 'docker-compose.integration-fixtures.yml' + } + if (-not ($composeFiles | Where-Object { Test-Path $_ })) { $composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.yml') + if ($QaIntegrationFixtures) { + $composeFiles += 'docker-compose.integration-fixtures.yml' + } } $totalContainers = 0 @@ -902,6 +980,9 @@ Initialize-EnvFile if ($InfraOnly) { Start-Infrastructure + if ($QaIntegrationFixtures) { + Start-QaIntegrationFixtures + } $infraSmokeFailed = Test-Smoke if ($infraSmokeFailed) { Write-Fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.' @@ -920,6 +1001,9 @@ if (-not $SkipImages) { } Start-Platform +if ($QaIntegrationFixtures) { + Start-QaIntegrationFixtures +} $platformSmokeFailed = Test-Smoke if ($platformSmokeFailed) { Write-Fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.' @@ -929,6 +1013,10 @@ if ($platformSmokeFailed) { Write-Host "`n=============================================" -ForegroundColor Green Write-Host ' Setup complete!' -ForegroundColor Green Write-Host ' Platform: https://stella-ops.local' -ForegroundColor Green +if ($QaIntegrationFixtures) { + Write-Host ' Harbor QA fixture: http://harbor-fixture.stella-ops.local/api/v2.0/health' -ForegroundColor Green + Write-Host ' GitHub App QA fixture: http://github-app-fixture.stella-ops.local/api/v3/app' -ForegroundColor Green +} Write-Host ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green Write-Host '=============================================' -ForegroundColor Green exit 0 diff --git a/scripts/setup.sh b/scripts/setup.sh index 0ecea10df..e4b8aa11c 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -11,6 +11,7 @@ SKIP_BUILD=false INFRA_ONLY=false IMAGES_ONLY=false SKIP_IMAGES=false +QA_INTEGRATION_FIXTURES=false for arg in "$@"; do case "$arg" in @@ -18,8 +19,9 @@ for arg in "$@"; do --infra-only) INFRA_ONLY=true ;; --images-only) IMAGES_ONLY=true ;; --skip-images) SKIP_IMAGES=true ;; + --qa-integration-fixtures) QA_INTEGRATION_FIXTURES=true ;; -h|--help) - echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images]" + echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images] [--qa-integration-fixtures]" exit 0 ;; *) echo "Unknown flag: $arg" >&2; exit 1 ;; @@ -326,16 +328,9 @@ check_prerequisites() { # ─── 2. Check and install hosts file ───────────────────────────────────── check_hosts() { - step 'Checking hosts file for stella-ops.local entries' + step 'Checking hosts file for required Stella Ops entries' local hosts_source="${ROOT}/devops/compose/hosts.stellaops.local" - if grep -q 'stella-ops\.local' /etc/hosts 2>/dev/null; then - ok 'stella-ops.local entries found in /etc/hosts' - return - fi - - warn 'stella-ops.local entries NOT found in /etc/hosts.' - if [[ ! -f "$hosts_source" ]]; then warn "Hosts source file not found at $hosts_source" echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' @@ -343,6 +338,42 @@ check_hosts() { return fi + local source_lines=() + while IFS= read -r line; do + [[ -z "${line// }" ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + source_lines+=("$line") + done < "$hosts_source" + + local missing_lines=() + local missing_hosts=() + local line hostname + for line in "${source_lines[@]}"; do + read -r -a tokens <<< "$line" + (( ${#tokens[@]} < 2 )) && continue + + local line_missing=false + for hostname in "${tokens[@]:1}"; do + if ! grep -Eq "(^|[[:space:]])${hostname}($|[[:space:]])" /etc/hosts 2>/dev/null; then + missing_hosts+=("$hostname") + line_missing=true + fi + done + + if [[ "$line_missing" == "true" ]]; then + missing_lines+=("$line") + fi + done + + if (( ${#missing_hosts[@]} == 0 )); then + ok 'All required Stella Ops host entries are present in /etc/hosts' + return + fi + + local unique_missing_hosts + unique_missing_hosts=$(printf '%s\n' "${missing_hosts[@]}" | awk 'NF { print }' | sort -u | paste -sd ', ' -) + warn "Missing Stella Ops host aliases: ${unique_missing_hosts}" + echo '' echo ' Stella Ops needs ~50 hosts file entries for local development.' echo " Source: devops/compose/hosts.stellaops.local" @@ -353,21 +384,21 @@ check_hosts() { if [[ -z "$answer" || "$answer" =~ ^[Yy] ]]; then if [[ "$(id -u)" -eq 0 ]]; then printf '\n' >> /etc/hosts - cat "$hosts_source" >> /etc/hosts - ok 'Hosts entries added successfully' + printf '%s\n' "${missing_lines[@]}" >> /etc/hosts + ok "Added ${#missing_lines[@]} missing host entry line(s) successfully" else echo '' echo ' Adding hosts entries requires sudo...' - if sudo sh -c "printf '\n' >> /etc/hosts && cat '$hosts_source' >> /etc/hosts"; then - ok 'Hosts entries added successfully' + if printf '%s\n' "${missing_lines[@]}" | sudo tee -a /etc/hosts >/dev/null; then + ok "Added ${#missing_lines[@]} missing host entry line(s) successfully" else warn 'Failed to add hosts entries. Add them manually:' - echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'" + echo " printf '%s\n' \"${missing_lines[@]}\" | sudo tee -a /etc/hosts" fi fi else warn 'Skipped. Add them manually before accessing the platform:' - echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'" + printf ' %s\n' "${missing_lines[@]}" fi } @@ -486,6 +517,15 @@ start_platform() { wait_for_compose_convergence 'Platform services converged from zero-state startup' true 180 45 docker-compose.stella-ops.yml || true } +start_qa_integration_fixtures() { + step 'Starting QA integration fixtures' + cd "$COMPOSE_DIR" + docker compose -f docker-compose.integration-fixtures.yml up -d + ok 'QA integration fixtures started' + cd "$ROOT" + wait_for_compose_convergence 'QA integration fixtures are healthy' false 90 30 docker-compose.integration-fixtures.yml || true +} + http_status() { local url="$1" local attempts="${2:-6}" @@ -597,6 +637,25 @@ smoke_test() { has_blocking_failures=true fi + if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then + local harbor_fixture_status github_fixture_status + harbor_fixture_status=$(http_status 'http://127.1.1.6/api/v2.0/health') + if [[ "$harbor_fixture_status" == "200" ]]; then + ok "Harbor QA fixture (HTTP $harbor_fixture_status)" + else + warn 'Harbor QA fixture did not respond with HTTP 200 on /api/v2.0/health' + has_blocking_failures=true + fi + + github_fixture_status=$(http_status 'http://127.1.1.7/api/v3/app') + if [[ "$github_fixture_status" == "200" ]]; then + ok "GitHub App QA fixture (HTTP $github_fixture_status)" + else + warn 'GitHub App QA fixture did not respond with HTTP 200 on /api/v3/app' + has_blocking_failures=true + fi + fi + if [[ "$INFRA_ONLY" != "true" ]]; then if ! frontdoor_bootstrap_ready; then has_blocking_failures=true @@ -614,8 +673,19 @@ smoke_test() { local total=0 local healthy=0 local unhealthy_names="" + local compose_files=() - for cf in docker-compose.dev.yml docker-compose.stella-ops.yml; do + if [[ "$INFRA_ONLY" == "true" ]]; then + compose_files+=(docker-compose.dev.yml) + else + compose_files+=(docker-compose.stella-ops.yml) + fi + + if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then + compose_files+=(docker-compose.integration-fixtures.yml) + fi + + for cf in "${compose_files[@]}"; do [[ ! -f "$cf" ]] && continue while IFS= read -r line; do [[ -z "$line" ]] && continue @@ -676,6 +746,9 @@ ensure_env start_infra if [[ "$INFRA_ONLY" == "true" ]]; then + if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then + start_qa_integration_fixtures + fi if ! smoke_test; then fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.' exit 1 @@ -698,6 +771,9 @@ if [[ "$SKIP_IMAGES" != "true" ]]; then fi start_platform +if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then + start_qa_integration_fixtures +fi if ! smoke_test; then fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.' exit 1 @@ -707,6 +783,10 @@ echo '' echo '=============================================' echo ' Setup complete!' echo ' Platform: https://stella-ops.local' +if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then + echo ' Harbor QA fixture: http://harbor-fixture.stella-ops.local/api/v2.0/health' + echo ' GitHub App QA fixture: http://github-app-fixture.stella-ops.local/api/v3/app' +fi echo ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' echo '=============================================' exit 0 diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs index e875ea840..45a8b2817 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; using static StellaOps.Localization.T; +using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Integrations.Contracts; using StellaOps.Integrations.Contracts.AiCodeGuard; @@ -59,10 +61,11 @@ public static class IntegrationEndpoints // Get integration by ID group.MapGet("/{id:guid}", async ( [FromServices] IntegrationService service, + [FromServices] IStellaOpsTenantAccessor tenantAccessor, Guid id, CancellationToken cancellationToken) => { - var result = await service.GetByIdAsync(id, cancellationToken); + var result = await service.GetByIdAsync(id, tenantAccessor.TenantId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }) .RequireAuthorization(IntegrationPolicies.Read) @@ -73,10 +76,12 @@ public static class IntegrationEndpoints group.MapPost("/", async ( [FromServices] IntegrationService service, [FromServices] IStellaOpsTenantAccessor tenantAccessor, + HttpContext httpContext, [FromBody] CreateIntegrationRequest request, CancellationToken cancellationToken) => { - var result = await service.CreateAsync(request, tenantAccessor.TenantId, null, cancellationToken); + var actorId = ResolveActorId(httpContext); + var result = await service.CreateAsync(request, tenantAccessor.TenantId, actorId, cancellationToken); return Results.Created($"/api/v1/integrations/{result.Id}", result); }) .RequireAuthorization(IntegrationPolicies.Write) @@ -87,11 +92,13 @@ public static class IntegrationEndpoints group.MapPut("/{id:guid}", async ( [FromServices] IntegrationService service, [FromServices] IStellaOpsTenantAccessor tenantAccessor, + HttpContext httpContext, Guid id, [FromBody] UpdateIntegrationRequest request, CancellationToken cancellationToken) => { - var result = await service.UpdateAsync(id, request, tenantAccessor.TenantId, cancellationToken); + var actorId = ResolveActorId(httpContext); + var result = await service.UpdateAsync(id, request, tenantAccessor.TenantId, actorId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }) .RequireAuthorization(IntegrationPolicies.Write) @@ -102,10 +109,12 @@ public static class IntegrationEndpoints group.MapDelete("/{id:guid}", async ( [FromServices] IntegrationService service, [FromServices] IStellaOpsTenantAccessor tenantAccessor, + HttpContext httpContext, Guid id, CancellationToken cancellationToken) => { - var result = await service.DeleteAsync(id, tenantAccessor.TenantId, cancellationToken); + var actorId = ResolveActorId(httpContext); + var result = await service.DeleteAsync(id, tenantAccessor.TenantId, actorId, cancellationToken); return result ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization(IntegrationPolicies.Write) @@ -116,10 +125,12 @@ public static class IntegrationEndpoints group.MapPost("/{id:guid}/test", async ( [FromServices] IntegrationService service, [FromServices] IStellaOpsTenantAccessor tenantAccessor, + HttpContext httpContext, Guid id, CancellationToken cancellationToken) => { - var result = await service.TestConnectionAsync(id, tenantAccessor.TenantId, cancellationToken); + var actorId = ResolveActorId(httpContext); + var result = await service.TestConnectionAsync(id, tenantAccessor.TenantId, actorId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }) .RequireAuthorization(IntegrationPolicies.Operate) @@ -129,10 +140,11 @@ public static class IntegrationEndpoints // Health check group.MapGet("/{id:guid}/health", async ( [FromServices] IntegrationService service, + [FromServices] IStellaOpsTenantAccessor tenantAccessor, Guid id, CancellationToken cancellationToken) => { - var result = await service.CheckHealthAsync(id, cancellationToken); + var result = await service.CheckHealthAsync(id, tenantAccessor.TenantId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }) .RequireAuthorization(IntegrationPolicies.Read) @@ -142,10 +154,11 @@ public static class IntegrationEndpoints // Impact map group.MapGet("/{id:guid}/impact", async ( [FromServices] IntegrationService service, + [FromServices] IStellaOpsTenantAccessor tenantAccessor, Guid id, CancellationToken cancellationToken) => { - var result = await service.GetImpactAsync(id, cancellationToken); + var result = await service.GetImpactAsync(id, tenantAccessor.TenantId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }) .RequireAuthorization(IntegrationPolicies.Read) @@ -162,4 +175,12 @@ public static class IntegrationEndpoints .WithName("GetSupportedProviders") .WithDescription(_t("integrations.integration.get_providers_description")); } + + private static string? ResolveActorId(HttpContext httpContext) + { + return httpContext.User.FindFirst(StellaOpsClaimTypes.Subject)?.Value + ?? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? httpContext.User.FindFirst("sub")?.Value + ?? httpContext.User.Identity?.Name; + } } diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs index f6353754a..4a9873f7a 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs @@ -38,7 +38,7 @@ public sealed class IntegrationService _logger = logger; } - public async Task CreateAsync(CreateIntegrationRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default) + public async Task CreateAsync(CreateIntegrationRequest request, string? tenantId, string? userId, CancellationToken cancellationToken = default) { var now = _timeProvider.GetUtcNow(); var integration = new Integration @@ -78,9 +78,9 @@ public sealed class IntegrationService return MapToResponse(created); } - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + public async Task GetByIdAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); return integration is null ? null : MapToResponse(integration); } @@ -110,9 +110,9 @@ public sealed class IntegrationService totalPages); } - public async Task UpdateAsync(Guid id, UpdateIntegrationRequest request, string? userId, CancellationToken cancellationToken = default) + public async Task UpdateAsync(Guid id, UpdateIntegrationRequest request, string? tenantId, string? userId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); if (integration is null) return null; var oldStatus = integration.Status; @@ -153,9 +153,9 @@ public sealed class IntegrationService return MapToResponse(updated); } - public async Task DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default) + public async Task DeleteAsync(Guid id, string? tenantId, string? userId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); if (integration is null) return false; await _repository.DeleteAsync(id, cancellationToken); @@ -172,9 +172,9 @@ public sealed class IntegrationService return true; } - public async Task TestConnectionAsync(Guid id, string? userId, CancellationToken cancellationToken = default) + public async Task TestConnectionAsync(Guid id, string? tenantId, string? userId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); if (integration is null) return null; var plugin = _pluginLoader.GetByProvider(integration.Provider); @@ -227,9 +227,9 @@ public sealed class IntegrationService endTime); } - public async Task CheckHealthAsync(Guid id, CancellationToken cancellationToken = default) + public async Task CheckHealthAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); if (integration is null) return null; var plugin = _pluginLoader.GetByProvider(integration.Provider); @@ -269,9 +269,9 @@ public sealed class IntegrationService result.Duration); } - public async Task GetImpactAsync(Guid id, CancellationToken cancellationToken = default) + public async Task GetImpactAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default) { - var integration = await _repository.GetByIdAsync(id, cancellationToken); + var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken); if (integration is null) { return null; @@ -302,6 +302,27 @@ public sealed class IntegrationService p.Provider)).ToList(); } + private async Task GetScopedIntegrationAsync(Guid id, string? tenantId, CancellationToken cancellationToken) + { + var integration = await _repository.GetByIdAsync(id, cancellationToken); + if (integration is null) + { + return null; + } + + if (!string.Equals(integration.TenantId, tenantId, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Integration {IntegrationId} was requested outside its tenant scope. requestedTenant={RequestedTenant} actualTenant={ActualTenant}", + id, + tenantId, + integration.TenantId); + return null; + } + + return integration; + } + private static IReadOnlyList BuildImpactedWorkflows(Integration integration) { var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived; diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/GitHubAppConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/GitHubAppConnectorPlugin.cs index e4c28f3c4..b4d85008c 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/GitHubAppConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/GitHubAppConnectorPlugin.cs @@ -41,7 +41,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin try { // Call GitHub API to verify authentication - var response = await client.GetAsync("/app", cancellationToken); + var response = await client.GetAsync("app", cancellationToken); var duration = _timeProvider.GetUtcNow() - startTime; if (response.IsSuccessStatusCode) @@ -98,7 +98,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin try { // Check GitHub API status - var response = await client.GetAsync("/rate_limit", cancellationToken); + var response = await client.GetAsync("rate_limit", cancellationToken); var duration = _timeProvider.GetUtcNow() - startTime; if (response.IsSuccessStatusCode) @@ -151,9 +151,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin private static HttpClient CreateHttpClient(IntegrationConfig config) { - var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com" - ? "https://api.github.com" - : config.Endpoint.TrimEnd('/') + "/api/v3"; + var baseUrl = ResolveBaseUrl(config.Endpoint); var client = new HttpClient { @@ -174,6 +172,22 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin return client; } + private static string ResolveBaseUrl(string? endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint) || endpoint.Equals("https://github.com", StringComparison.OrdinalIgnoreCase)) + { + return "https://api.github.com/"; + } + + var normalized = endpoint.TrimEnd('/'); + if (normalized.EndsWith("/api/v3", StringComparison.OrdinalIgnoreCase)) + { + return normalized + "/"; + } + + return normalized + "/api/v3/"; + } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/GitHubAppConnectorPluginTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/GitHubAppConnectorPluginTests.cs new file mode 100644 index 000000000..35a2b45b2 --- /dev/null +++ b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/GitHubAppConnectorPluginTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using StellaOps.Integrations.Core; +using StellaOps.Integrations.Plugin.GitHubApp; +using Xunit; + +namespace StellaOps.Integrations.Plugin.Tests; + +/// +/// Focused transport-level tests for GitHubAppConnectorPlugin route construction. +/// +public sealed class GitHubAppConnectorPluginTests +{ + private static readonly DateTimeOffset FixedTime = new(2026, 3, 14, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task TestConnectionAsync_UsesApiV3AppRoute_ForEnterpriseBaseEndpoint() + { + using var fixture = LoopbackHttpFixture.Start(path => path switch + { + "/api/v3/app" => HttpResponse.Json("""{"id":424242,"name":"Stella QA GitHub App","slug":"stella-qa-app"}"""), + _ => HttpResponse.Text("unexpected-path"), + }); + + var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime)); + + var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl)); + var requestedPath = await fixture.WaitForPathAsync(); + + Assert.True(result.Success); + Assert.Equal("/api/v3/app", requestedPath); + Assert.Contains("Stella QA GitHub App", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TestConnectionAsync_DoesNotDuplicateApiV3_WhenEndpointAlreadyIncludesIt() + { + using var fixture = LoopbackHttpFixture.Start(path => path switch + { + "/api/v3/app" => HttpResponse.Json("""{"id":424242,"name":"Stella QA GitHub App","slug":"stella-qa-app"}"""), + _ => HttpResponse.Text("unexpected-path"), + }); + + var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime)); + + var result = await plugin.TestConnectionAsync(CreateConfig($"{fixture.BaseUrl}/api/v3")); + var requestedPath = await fixture.WaitForPathAsync(); + + Assert.True(result.Success); + Assert.Equal("/api/v3/app", requestedPath); + } + + [Fact] + public async Task CheckHealthAsync_UsesApiV3RateLimitRoute_ForEnterpriseBaseEndpoint() + { + using var fixture = LoopbackHttpFixture.Start(path => path switch + { + "/api/v3/rate_limit" => HttpResponse.Json("""{"resources":{"core":{"limit":5000,"remaining":4991,"reset":1893456000}}}"""), + _ => HttpResponse.Text("unexpected-path"), + }); + + var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime)); + + var result = await plugin.CheckHealthAsync(CreateConfig(fixture.BaseUrl)); + var requestedPath = await fixture.WaitForPathAsync(); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal("/api/v3/rate_limit", requestedPath); + Assert.Contains("Rate limit", result.Message, StringComparison.Ordinal); + } + + private static IntegrationConfig CreateConfig(string endpoint) + { + return new IntegrationConfig( + IntegrationId: Guid.NewGuid(), + Type: IntegrationType.Scm, + Provider: IntegrationProvider.GitHubApp, + Endpoint: endpoint, + ResolvedSecret: null, + OrganizationId: "stellaops", + ExtendedConfig: new Dictionary + { + ["appId"] = "424242", + ["installationId"] = "424243", + }); + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _fixedTime; + + public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime; + + public override DateTimeOffset GetUtcNow() => _fixedTime; + } + + private sealed class LoopbackHttpFixture : IDisposable + { + private readonly TcpListener _listener; + private readonly Task _requestPathTask; + + private LoopbackHttpFixture(Func responder) + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}"; + _requestPathTask = HandleSingleRequestAsync(responder); + } + + public string BaseUrl { get; } + + public static LoopbackHttpFixture Start(Func responder) => new(responder); + + public Task WaitForPathAsync() => _requestPathTask; + + public void Dispose() + { + try + { + _listener.Stop(); + } + catch + { + } + } + + private async Task HandleSingleRequestAsync(Func responder) + { + using var client = await _listener.AcceptTcpClientAsync(); + using var stream = client.GetStream(); + using var reader = new StreamReader( + stream, + Encoding.ASCII, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + + var requestLine = await reader.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(requestLine)) + { + throw new InvalidOperationException("Did not receive an HTTP request line."); + } + + var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (requestParts.Length < 2) + { + throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}"); + } + + while (true) + { + var headerLine = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(headerLine)) + { + break; + } + } + + var requestPath = requestParts[1]; + var response = responder(requestPath); + var payload = Encoding.UTF8.GetBytes(response.Body); + var responseText = + $"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" + + $"Content-Type: {response.ContentType}\r\n" + + $"Content-Length: {payload.Length}\r\n" + + "Connection: close\r\n" + + "\r\n"; + + var headerBytes = Encoding.ASCII.GetBytes(responseText); + await stream.WriteAsync(headerBytes); + await stream.WriteAsync(payload); + await stream.FlushAsync(); + return requestPath; + } + } + + private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body) + { + public static HttpResponse Json(string body) => new(200, "OK", "application/json", body); + + public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body); + } +} diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj index 95c50e916..d8a066dea 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj +++ b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs index 5002f3a58..838db69c0 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs @@ -30,6 +30,42 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture { ["source"] = "integration-test" }, + Tags: ["qa", "tenant"]); + + var createResponse = await _client.PostAsJsonAsync( + "/api/v1/integrations/", + createRequest, + TestContext.Current.CancellationToken); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.Equal("test-user", created!.CreatedBy); + + var list = await _client.GetFromJsonAsync( + $"/api/v1/integrations/?type={(int)IntegrationType.Registry}", + TestContext.Current.CancellationToken); + + Assert.NotNull(list); + Assert.Equal(1, list!.TotalCount); + var item = Assert.Single(list.Items); + Assert.Equal(created.Id, item.Id); + Assert.Equal("test-user", item.CreatedBy); + } + [Trait("Category", TestCategories.Integration)] [Fact] public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap() @@ -220,7 +256,41 @@ internal sealed class InMemoryIntegrationRepository : IIntegrationRepository { lock (_gate) { - return Task.FromResult(_items.Values.Count(item => query.IncludeDeleted || !item.IsDeleted)); + IEnumerable values = _items.Values; + + if (!query.IncludeDeleted) + { + values = values.Where(item => !item.IsDeleted); + } + + if (query.Type.HasValue) + { + values = values.Where(item => item.Type == query.Type.Value); + } + + if (query.Provider.HasValue) + { + values = values.Where(item => item.Provider == query.Provider.Value); + } + + if (query.Status.HasValue) + { + values = values.Where(item => item.Status == query.Status.Value); + } + + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + values = values.Where(item => string.Equals(item.TenantId, query.TenantId, StringComparison.Ordinal)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + values = values.Where(item => + item.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase) || + (item.Description?.Contains(query.Search, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + return Task.FromResult(values.Count()); } } diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs index 7f1945f3a..00bd8a413 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace StellaOps.Integrations.Tests; -public class IntegrationServiceTests +public sealed class IntegrationServiceTests { private readonly Mock _repositoryMock; private readonly Mock _eventPublisherMock; @@ -38,9 +38,8 @@ public class IntegrationServiceTests [Trait("Category", "Unit")] [Fact] - public async Task CreateAsync_WithValidRequest_CreatesIntegration() + public async Task CreateAsync_WithValidRequest_PersistsTenantAndActor() { - // Arrange var request = new CreateIntegrationRequest( Name: "Test Registry", Description: "Test description", @@ -48,55 +47,43 @@ public class IntegrationServiceTests Provider: IntegrationProvider.Harbor, Endpoint: "https://harbor.example.com", AuthRefUri: "authref://vault/harbor#credentials", - OrganizationId: "myorg", + OrganizationId: "platform", ExtendedConfig: null, Tags: ["test", "dev"]); _repositoryMock .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) - .Returns((i, _) => Task.FromResult(i)); + .Returns((integration, _) => Task.FromResult(integration)); - // Act - var result = await _service.CreateAsync(request, "test-user", "tenant-1"); + var result = await _service.CreateAsync(request, "tenant-1", "test-user"); - // Assert result.Should().NotBeNull(); result.Name.Should().Be("Test Registry"); - result.Type.Should().Be(IntegrationType.Registry); - result.Provider.Should().Be(IntegrationProvider.Harbor); - result.Status.Should().Be(IntegrationStatus.Pending); result.Endpoint.Should().Be("https://harbor.example.com"); + result.CreatedBy.Should().Be("test-user"); + result.UpdatedBy.Should().Be("test-user"); - _repositoryMock.Verify(r => r.CreateAsync( - It.IsAny(), - It.IsAny()), Times.Once); - - _eventPublisherMock.Verify(e => e.PublishAsync( - It.IsAny(), - It.IsAny()), Times.Once); - - _auditLoggerMock.Verify(a => a.LogAsync( - "integration.created", - It.IsAny(), - "test-user", - It.IsAny(), - It.IsAny()), Times.Once); + _repositoryMock.Verify( + r => r.CreateAsync( + It.Is(integration => + integration.TenantId == "tenant-1" && + integration.CreatedBy == "test-user" && + integration.UpdatedBy == "test-user"), + It.IsAny()), + Times.Once); } [Trait("Category", "Unit")] [Fact] - public async Task GetByIdAsync_WithExistingId_ReturnsIntegration() + public async Task GetByIdAsync_WithMatchingTenant_ReturnsIntegration() { - // Arrange var integration = CreateTestIntegration(); _repositoryMock .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) .ReturnsAsync(integration); - // Act - var result = await _service.GetByIdAsync(integration.Id); + var result = await _service.GetByIdAsync(integration.Id, "tenant-1"); - // Assert result.Should().NotBeNull(); result!.Id.Should().Be(integration.Id); result.Name.Should().Be(integration.Name); @@ -104,61 +91,56 @@ public class IntegrationServiceTests [Trait("Category", "Unit")] [Fact] - public async Task GetByIdAsync_WithNonExistingId_ReturnsNull() + public async Task GetByIdAsync_WithTenantMismatch_ReturnsNull() { - // Arrange - var id = Guid.NewGuid(); + var integration = CreateTestIntegration(tenantId: "tenant-a"); _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((Integration?)null); + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); - // Act - var result = await _service.GetByIdAsync(id); + var result = await _service.GetByIdAsync(integration.Id, "tenant-b"); - // Assert result.Should().BeNull(); } [Trait("Category", "Unit")] [Fact] - public async Task ListAsync_WithFilters_ReturnsFilteredResults() + public async Task ListAsync_WithFilters_ScopesRepositoryQueryToTenant() { - // Arrange var integrations = new[] { CreateTestIntegration(type: IntegrationType.Registry), CreateTestIntegration(type: IntegrationType.Registry), - CreateTestIntegration(type: IntegrationType.Scm) + CreateTestIntegration(type: IntegrationType.Scm), }; _repositoryMock .Setup(r => r.GetAllAsync( - It.Is(q => q.Type == IntegrationType.Registry), + It.Is(query => + query.Type == IntegrationType.Registry && + query.TenantId == "tenant-1"), It.IsAny())) .ReturnsAsync(integrations.Where(i => i.Type == IntegrationType.Registry).ToList()); _repositoryMock .Setup(r => r.CountAsync( - It.Is(q => q.Type == IntegrationType.Registry), + It.Is(query => + query.Type == IntegrationType.Registry && + query.TenantId == "tenant-1"), It.IsAny())) .ReturnsAsync(2); - var query = new ListIntegrationsQuery(Type: IntegrationType.Registry); + var result = await _service.ListAsync(new ListIntegrationsQuery(Type: IntegrationType.Registry), "tenant-1"); - // Act - var result = await _service.ListAsync(query, "tenant-1"); - - // Assert result.Items.Should().HaveCount(2); - result.Items.Should().OnlyContain(i => i.Type == IntegrationType.Registry); + result.Items.Should().OnlyContain(item => item.Type == IntegrationType.Registry); result.TotalCount.Should().Be(2); } [Trait("Category", "Unit")] [Fact] - public async Task UpdateAsync_WithExistingIntegration_UpdatesAndPublishesEvent() + public async Task UpdateAsync_WithMatchingTenant_UpdatesAndPublishesEvent() { - // Arrange var integration = CreateTestIntegration(); var request = new UpdateIntegrationRequest( Name: "Updated Name", @@ -175,49 +157,49 @@ public class IntegrationServiceTests .ReturnsAsync(integration); _repositoryMock .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) - .Returns((i, _) => Task.FromResult(i)); + .Returns((updated, _) => Task.FromResult(updated)); - // Act - var result = await _service.UpdateAsync(integration.Id, request, "test-user"); + var result = await _service.UpdateAsync(integration.Id, request, "tenant-1", "test-user"); - // Assert result.Should().NotBeNull(); result!.Name.Should().Be("Updated Name"); result.Description.Should().Be("Updated description"); result.Endpoint.Should().Be("https://updated.example.com"); - _eventPublisherMock.Verify(e => e.PublishAsync( - It.IsAny(), - It.IsAny()), Times.Once); + _eventPublisherMock.Verify( + publisher => publisher.PublishAsync(It.IsAny(), It.IsAny()), + Times.Once); } [Trait("Category", "Unit")] [Fact] - public async Task UpdateAsync_WithNonExistingIntegration_ReturnsNull() + public async Task UpdateAsync_WithTenantMismatch_ReturnsNullWithoutMutation() { - // Arrange - var id = Guid.NewGuid(); - _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((Integration?)null); - + var integration = CreateTestIntegration(tenantId: "tenant-a"); var request = new UpdateIntegrationRequest( - Name: "Updated", Description: null, Endpoint: null, - AuthRefUri: null, OrganizationId: null, ExtendedConfig: null, - Tags: null, Status: null); + Name: "Updated", + Description: null, + Endpoint: null, + AuthRefUri: null, + OrganizationId: null, + ExtendedConfig: null, + Tags: null, + Status: null); - // Act - var result = await _service.UpdateAsync(id, request, "test-user"); + _repositoryMock + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); + + var result = await _service.UpdateAsync(integration.Id, request, "tenant-b", "test-user"); - // Assert result.Should().BeNull(); + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); } [Trait("Category", "Unit")] [Fact] - public async Task DeleteAsync_WithExistingIntegration_DeletesAndPublishesEvent() + public async Task DeleteAsync_WithMatchingTenant_DeletesAndPublishesEvent() { - // Arrange var integration = CreateTestIntegration(); _repositoryMock .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) @@ -226,51 +208,38 @@ public class IntegrationServiceTests .Setup(r => r.DeleteAsync(integration.Id, It.IsAny())) .Returns(Task.CompletedTask); - // Act - var result = await _service.DeleteAsync(integration.Id, "test-user"); + var result = await _service.DeleteAsync(integration.Id, "tenant-1", "test-user"); - // Assert result.Should().BeTrue(); _repositoryMock.Verify(r => r.DeleteAsync(integration.Id, It.IsAny()), Times.Once); - - _eventPublisherMock.Verify(e => e.PublishAsync( - It.IsAny(), - It.IsAny()), Times.Once); } [Trait("Category", "Unit")] [Fact] - public async Task DeleteAsync_WithNonExistingIntegration_ReturnsFalse() + public async Task DeleteAsync_WithTenantMismatch_ReturnsFalseWithoutDelete() { - // Arrange - var id = Guid.NewGuid(); + var integration = CreateTestIntegration(tenantId: "tenant-a"); _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((Integration?)null); + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); - // Act - var result = await _service.DeleteAsync(id, "test-user"); + var result = await _service.DeleteAsync(integration.Id, "tenant-b", "test-user"); - // Assert result.Should().BeFalse(); + _repositoryMock.Verify(r => r.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); } [Trait("Category", "Unit")] [Fact] - public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResult() + public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResultForMatchingTenant() { - // Arrange var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor); _repositoryMock .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) .ReturnsAsync(integration); - // No plugins loaded in _pluginLoader + var result = await _service.TestConnectionAsync(integration.Id, "tenant-1", "test-user"); - // Act - var result = await _service.TestConnectionAsync(integration.Id, "test-user"); - - // Assert result.Should().NotBeNull(); result!.Success.Should().BeFalse(); result.Message.Should().Contain("No connector plugin"); @@ -278,66 +247,65 @@ public class IntegrationServiceTests [Trait("Category", "Unit")] [Fact] - public async Task TestConnectionAsync_WithNonExistingIntegration_ReturnsNull() + public async Task TestConnectionAsync_WithTenantMismatch_ReturnsNull() { - // Arrange - var id = Guid.NewGuid(); + var integration = CreateTestIntegration(tenantId: "tenant-a"); _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((Integration?)null); + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); - // Act - var result = await _service.TestConnectionAsync(id, "test-user"); + var result = await _service.TestConnectionAsync(integration.Id, "tenant-b", "test-user"); - // Assert result.Should().BeNull(); } [Trait("Category", "Unit")] [Fact] - public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatus() + public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatusForMatchingTenant() { - // Arrange var integration = CreateTestIntegration(); _repositoryMock .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) .ReturnsAsync(integration); - // No plugins loaded + var result = await _service.CheckHealthAsync(integration.Id, "tenant-1"); - // Act - var result = await _service.CheckHealthAsync(integration.Id); - - // Assert result.Should().NotBeNull(); result!.Status.Should().Be(HealthStatus.Unknown); } [Trait("Category", "Unit")] [Fact] - public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty() + public async Task CheckHealthAsync_WithTenantMismatch_ReturnsNull() { - // Act - var result = _service.GetSupportedProviders(); + var integration = CreateTestIntegration(tenantId: "tenant-a"); + _repositoryMock + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); - // Assert - result.Should().BeEmpty(); + var result = await _service.CheckHealthAsync(integration.Id, "tenant-b"); + + result.Should().BeNull(); } [Trait("Category", "Unit")] [Fact] - public async Task GetImpactAsync_WithNonExistingIntegration_ReturnsNull() + public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty() { - // Arrange - var id = Guid.NewGuid(); + _service.GetSupportedProviders().Should().BeEmpty(); + } + + [Trait("Category", "Unit")] + [Fact] + public async Task GetImpactAsync_WithTenantMismatch_ReturnsNull() + { + var integration = CreateTestIntegration(tenantId: "tenant-a"); _repositoryMock - .Setup(r => r.GetByIdAsync(id, It.IsAny())) - .ReturnsAsync((Integration?)null); + .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) + .ReturnsAsync(integration); - // Act - var result = await _service.GetImpactAsync(id); + var result = await _service.GetImpactAsync(integration.Id, "tenant-b"); - // Assert result.Should().BeNull(); } @@ -345,7 +313,6 @@ public class IntegrationServiceTests [Fact] public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity() { - // Arrange var integration = CreateTestIntegration( type: IntegrationType.FeedMirror, provider: IntegrationProvider.NvdMirror); @@ -356,10 +323,8 @@ public class IntegrationServiceTests .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny())) .ReturnsAsync(integration); - // Act - var result = await _service.GetImpactAsync(integration.Id); + var result = await _service.GetImpactAsync(integration.Id, "tenant-1"); - // Assert result.Should().NotBeNull(); result!.Severity.Should().Be("high"); result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount); @@ -370,7 +335,8 @@ public class IntegrationServiceTests private static Integration CreateTestIntegration( IntegrationType type = IntegrationType.Registry, - IntegrationProvider provider = IntegrationProvider.Harbor) + IntegrationProvider provider = IntegrationProvider.Harbor, + string tenantId = "tenant-1") { var now = DateTimeOffset.UtcNow; return new Integration @@ -382,10 +348,12 @@ public class IntegrationServiceTests Status = IntegrationStatus.Active, Endpoint = "https://example.com", Description = "Test description", + TenantId = tenantId, Tags = ["test"], CreatedBy = "test-user", + UpdatedBy = "test-user", CreatedAt = now, - UpdatedAt = now + UpdatedAt = now, }; } } diff --git a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs index 21028ff52..d9d4bebf4 100644 --- a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs +++ b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs @@ -37,6 +37,16 @@ const suites = [ script: 'live-integrations-action-sweep.mjs', reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'), }, + { + name: 'integrations-onboarding-persistence-check', + script: 'live-integrations-onboarding-persistence-check.mjs', + reportPath: path.join(outputDir, 'live-integrations-onboarding-persistence-check.json'), + }, + { + name: 'integrations-onboarding-success-fixtures-check', + script: 'live-integrations-onboarding-success-fixtures-check.mjs', + reportPath: path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.json'), + }, { name: 'setup-topology-action-sweep', script: 'live-setup-topology-action-sweep.mjs', diff --git a/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-persistence-check.mjs b/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-persistence-check.mjs new file mode 100644 index 000000000..c4aed73df --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-persistence-check.mjs @@ -0,0 +1,362 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.json'); +const authStatePath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.state.json'); +const authReportPath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +function scopedUrl(route) { + const separator = route.includes('?') ? '&' : '?'; + return `https://stella-ops.local${route}${separator}${scopeQuery}`; +} + +async function settle(page, waitMs = 1_500) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(waitMs); +} + +async function headingText(page) { + const headings = page.locator('h1, h2'); + const count = await headings.count(); + for (let index = 0; index < Math.min(count, 4); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .check-item.status-error, .error-state, .result-card, .detail-state') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .slice(0, 6), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + }; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +async function recordCheck(summary, label, runner) { + const startedAtUtc = new Date().toISOString(); + + try { + const result = await runner(); + summary.checks.push({ + label, + ok: result?.ok ?? true, + startedAtUtc, + ...result, + }); + } catch (error) { + summary.checks.push({ + label, + ok: false, + startedAtUtc, + error: error instanceof Error ? error.message : String(error), + }); + } + + await persistSummary(summary); +} + +async function waitForEnabled(locator, timeoutMs = 15_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if ((await locator.count().catch(() => 0)) > 0 && await locator.isEnabled().catch(() => false)) { + return; + } + + await locator.page().waitForTimeout(250); + } + + throw new Error(`Timed out waiting for control to enable after ${timeoutMs}ms.`); +} + +async function openRegistryWizard(page) { + const authHeading = page.getByRole('heading', { name: /Connection & Credentials/i }); + const harborButton = page.getByRole('button', { name: /Harbor/i }); + const startedAt = Date.now(); + + while (Date.now() - startedAt < 15_000) { + if (await authHeading.isVisible().catch(() => false)) { + return; + } + + if (await harborButton.isVisible().catch(() => false)) { + await harborButton.click({ timeout: 10_000 }); + await authHeading.waitFor({ timeout: 15_000 }); + return; + } + + await page.waitForTimeout(250); + } + + throw new Error('Timed out waiting for the registry onboarding wizard to reach provider or auth state.'); +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + const page = await context.newPage(); + + const runtime = { + consoleErrors: [], + pageErrors: [], + responseErrors: [], + requestFailures: [], + }; + + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ page: page.url(), text: message.text() }); + } + }); + page.on('pageerror', (error) => { + runtime.pageErrors.push({ page: page.url(), message: error.message }); + }); + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url, + error: errorText, + }); + }); + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url, + }); + } + }); + + const summary = { + generatedAtUtc: new Date().toISOString(), + runtime, + checks: [], + cleanup: null, + }; + + let createdIntegrationId = null; + + try { + await page.goto(scopedUrl('/ops/integrations/onboarding'), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + + await recordCheck(summary, 'onboarding-hub-ui-contract', async () => { + const registryPills = await page.locator('.category-section').nth(0).locator('.provider-pill').allInnerTexts(); + const scmPills = await page.locator('.category-section').nth(1).locator('.provider-pill').allInnerTexts(); + const ciButtonDisabled = await page.getByRole('button', { name: /Add CI\/CD/i }).isDisabled(); + const hostButtonDisabled = await page.getByRole('button', { name: /Add Host/i }).isDisabled(); + + return { + ok: registryPills.join(',') === 'Harbor' + && scmPills.join(',') === 'GitHub App' + && ciButtonDisabled + && hostButtonDisabled, + registryPills, + scmPills, + ciButtonDisabled, + hostButtonDisabled, + snapshot: await captureSnapshot(page, 'onboarding-hub-ui-contract'), + }; + }); + + const addRegistryButton = page.getByRole('button', { name: /Add Registry/i }); + await addRegistryButton.click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname.includes('/ops/integrations/onboarding/registry'), { timeout: 15_000 }); + await openRegistryWizard(page); + await settle(page); + + await recordCheck(summary, 'typed-onboarding-route', async () => ({ + ok: page.url().includes('/ops/integrations/onboarding/registry') + && page.url().includes('tenant=demo-prod') + && page.url().includes('regions=us-east') + && page.url().includes('environments=stage') + && page.url().includes('timeWindow=7d'), + snapshot: await captureSnapshot(page, 'typed-onboarding-route'), + })); + + const uniqueSuffix = Date.now().toString(); + const integrationName = `QA Harbor ${uniqueSuffix}`; + + await page.getByLabel(/Endpoint/i).fill('https://harbor-ui-qa.invalid'); + await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/harbor#robot-${uniqueSuffix}`); + await page.getByLabel(/Project \/ Namespace/i).fill('qa-platform'); + await page.getByRole('button', { name: /^Next$/i }).click(); + await page.getByRole('heading', { name: /Discovery Scope/i }).waitFor({ timeout: 15_000 }); + + await page.getByLabel(/Namespaces \/ Projects/i).fill('qa-platform'); + await page.getByLabel(/Tag Patterns/i).fill('release-*'); + await page.getByRole('button', { name: /^Next$/i }).click(); + await page.getByRole('heading', { name: /Check Schedule/i }).waitFor({ timeout: 15_000 }); + + await page.getByRole('button', { name: /^Next$/i }).click(); + await page.getByRole('heading', { name: /Preflight Checks/i }).waitFor({ timeout: 15_000 }); + + const nextButton = page.getByRole('button', { name: /^Next$/i }); + await waitForEnabled(nextButton); + await nextButton.click(); + await page.getByRole('heading', { name: /Review & Create/i }).waitFor({ timeout: 15_000 }); + + await page.getByLabel(/Integration Name/i).fill(integrationName); + await page.getByRole('button', { name: /Create Integration/i }).click(); + await page.waitForURL( + (url) => url.pathname.startsWith('/ops/integrations/') && !url.pathname.includes('/onboarding/'), + { timeout: 20_000 }, + ); + await settle(page); + + createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null; + + await recordCheck(summary, 'ui-create-and-detail-render', async () => { + const pageText = await page.locator('body').innerText(); + + return { + ok: pageText.includes(integrationName) + && pageText.includes('https://harbor-ui-qa.invalid') + && pageText.includes('Configured via AuthRef'), + createdIntegrationId, + snapshot: await captureSnapshot(page, 'ui-create-and-detail-render'), + }; + }); + + await page.getByRole('button', { name: /^Health$/i }).click(); + await settle(page, 750); + await page.getByRole('button', { name: /Test Connection/i }).click(); + await page.getByRole('heading', { name: /Last Test Result/i }).waitFor({ timeout: 15_000 }); + await settle(page); + + await recordCheck(summary, 'detail-test-connection-action', async () => { + const resultCardText = await page.locator('.result-card').first().innerText(); + return { + ok: /Success|Failed/i.test(resultCardText) && resultCardText.trim().length > 0, + resultCardText, + snapshot: await captureSnapshot(page, 'detail-test-connection-action'), + }; + }); + + await page.getByRole('button', { name: /Check Health/i }).click(); + await page.getByRole('heading', { name: /Last Health Check/i }).waitFor({ timeout: 15_000 }); + await settle(page); + + await recordCheck(summary, 'detail-health-action', async () => { + const resultCards = await page.locator('.result-card').allInnerTexts(); + const combined = resultCards.join(' | '); + return { + ok: /Healthy|Degraded|Unhealthy|Unknown/i.test(combined) && combined.trim().length > 0, + resultCards, + snapshot: await captureSnapshot(page, 'detail-health-action'), + }; + }); + } finally { + if (createdIntegrationId) { + try { + if (!page.url().includes(`/ops/integrations/${createdIntegrationId}`)) { + await page.goto(scopedUrl(`/ops/integrations/${createdIntegrationId}`), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + } + + await page.getByRole('button', { name: /^Credentials$/i }).click({ timeout: 10_000 }); + await settle(page, 750); + page.once('dialog', (dialog) => dialog.accept().catch(() => {})); + await page.getByRole('button', { name: /Delete Integration/i }).click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname === '/ops/integrations', { timeout: 15_000 }); + summary.cleanup = { + createdIntegrationId, + ok: true, + finalUrl: page.url(), + }; + } catch (error) { + summary.cleanup = { + createdIntegrationId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + summary.failedCheckCount = summary.checks.filter((check) => check.ok === false).length; + summary.runtimeIssueCount = + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.responseErrors.length + + runtime.requestFailures.length; + + await persistSummary(summary).catch(() => {}); + await browser.close().catch(() => {}); + } + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + process.stderr.write( + `[live-integrations-onboarding-persistence-check] ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-success-fixtures-check.mjs b/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-success-fixtures-check.mjs new file mode 100644 index 000000000..d5128491b --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-integrations-onboarding-success-fixtures-check.mjs @@ -0,0 +1,491 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.json'); +const authStatePath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.state.json'); +const authReportPath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +function scopedUrl(route) { + const separator = route.includes('?') ? '&' : '?'; + return `https://stella-ops.local${route}${separator}${scopeQuery}`; +} + +async function settle(page, waitMs = 1_000) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(waitMs); +} + +async function headingText(page) { + const headings = page.locator('h1, h2'); + const count = await headings.count(); + for (let index = 0; index < Math.min(count, 4); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .check-item.status-error, .error-state, .result-card, .detail-state') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .slice(0, 8), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + }; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +async function recordCheck(summary, label, runner) { + const startedAtUtc = new Date().toISOString(); + + try { + const result = await runner(); + summary.checks.push({ + label, + ok: result?.ok ?? true, + startedAtUtc, + ...result, + }); + } catch (error) { + summary.checks.push({ + label, + ok: false, + startedAtUtc, + error: error instanceof Error ? error.message : String(error), + }); + } + + await persistSummary(summary); +} + +async function waitForEnabled(locator, timeoutMs = 15_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if ((await locator.count().catch(() => 0)) > 0 && await locator.isEnabled().catch(() => false)) { + return; + } + + await locator.page().waitForTimeout(250); + } + + throw new Error(`Timed out waiting for control to enable after ${timeoutMs}ms.`); +} + +async function openSetupOnboarding(page) { + await page.goto(scopedUrl('/setup/integrations'), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + + await page.getByRole('button', { name: /\+ Add Integration/i }).click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname === '/setup/integrations/onboarding', { timeout: 15_000 }); + await settle(page); +} + +async function openTypedWizard(page, triggerName) { + const authHeading = page.getByRole('heading', { name: /Connection & Credentials/i }); + const triggerButton = page.getByRole('button', { name: triggerName }); + const startedAt = Date.now(); + + while (Date.now() - startedAt < 15_000) { + if (await authHeading.isVisible().catch(() => false)) { + return; + } + + if (await triggerButton.isVisible().catch(() => false)) { + await triggerButton.click({ timeout: 10_000 }); + await authHeading.waitFor({ timeout: 15_000 }); + return; + } + + await page.waitForTimeout(250); + } + + throw new Error(`Timed out waiting for ${triggerName} onboarding wizard.`); +} + +async function advanceWizardToReview(page, { scopeHeading, fillScope }) { + await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 }); + await page.getByRole('heading', { name: scopeHeading }).waitFor({ timeout: 15_000 }); + await fillScope(); + + await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 }); + await page.getByRole('heading', { name: /Check Schedule/i }).waitFor({ timeout: 15_000 }); + + await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 }); + await page.getByRole('heading', { name: /Preflight Checks/i }).waitFor({ timeout: 15_000 }); + + const nextButton = page.getByRole('button', { name: /^Next$/i }); + await waitForEnabled(nextButton); + await nextButton.click({ timeout: 10_000 }); + await page.getByRole('heading', { name: /Review & Create/i }).waitFor({ timeout: 15_000 }); +} + +async function waitForDetailRoute(page) { + await page.waitForURL( + (url) => url.pathname.startsWith('/setup/integrations/') && !url.pathname.includes('/onboarding/'), + { timeout: 20_000 }, + ); + await settle(page); +} + +async function testDetailActions(page, providerName, expectedSuccessPattern, expectedHealthPattern) { + await page.getByRole('button', { name: /^Health$/i }).click({ timeout: 10_000 }); + await settle(page, 500); + + await page.getByRole('button', { name: /Test Connection/i }).click({ timeout: 10_000 }); + await page.getByRole('heading', { name: /Last Test Result/i }).waitFor({ timeout: 15_000 }); + await settle(page); + + const testCardText = await page.locator('.result-card').first().innerText(); + if (!expectedSuccessPattern.test(testCardText)) { + throw new Error(`${providerName} test connection did not match expected success pattern. Saw: ${testCardText}`); + } + + await page.getByRole('button', { name: /Check Health/i }).click({ timeout: 10_000 }); + await page.getByRole('heading', { name: /Last Health Check/i }).waitFor({ timeout: 15_000 }); + await settle(page); + + const resultCards = await page.locator('.result-card').allInnerTexts(); + const combined = resultCards.join(' | '); + if (!expectedHealthPattern.test(combined)) { + throw new Error(`${providerName} health check did not match expected health pattern. Saw: ${combined}`); + } + + return { + testCardText, + resultCards, + combined, + }; +} + +async function deleteIntegration(page, detailPath) { + if (!page.url().includes(detailPath)) { + await page.goto(scopedUrl(detailPath), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + } + + await page.getByRole('button', { name: /^Credentials$/i }).click({ timeout: 10_000 }); + await settle(page, 500); + page.once('dialog', (dialog) => dialog.accept().catch(() => {})); + await page.getByRole('button', { name: /Delete Integration/i }).click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname === '/setup/integrations', { timeout: 15_000 }); + await settle(page); +} + +async function createHarborIntegration(page, summary, uniqueSuffix) { + await openSetupOnboarding(page); + await openTypedWizard(page, /\+ Add Registry/i); + await settle(page); + + const integrationName = `QA Harbor Fixture ${uniqueSuffix}`; + await page.getByLabel(/Endpoint/i).fill('http://harbor-fixture.stella-ops.local'); + await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/harbor#robot-${uniqueSuffix}`); + await page.getByLabel(/Project \/ Namespace/i).fill('qa-platform'); + + await advanceWizardToReview(page, { + scopeHeading: /Discovery Scope/i, + fillScope: async () => { + await page.getByLabel(/Namespaces \/ Projects/i).fill('qa-platform'); + await page.getByLabel(/Tag Patterns/i).fill('release-*'); + }, + }); + + await page.getByLabel(/Integration Name/i).fill(integrationName); + await page.getByRole('button', { name: /Create Integration/i }).click({ timeout: 10_000 }); + await waitForDetailRoute(page); + + const createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null; + const pageText = await page.locator('body').innerText(); + + await recordCheck(summary, 'setup-harbor-create-and-detail-render', async () => ({ + ok: Boolean(createdIntegrationId) + && page.url().includes('/setup/integrations/') + && pageText.includes(integrationName) + && pageText.includes('http://harbor-fixture.stella-ops.local') + && pageText.includes('Configured via AuthRef'), + createdIntegrationId, + integrationName, + snapshot: await captureSnapshot(page, 'setup-harbor-create-and-detail-render'), + })); + + const actionResult = await testDetailActions( + page, + 'Harbor', + /Harbor connection successful/i, + /Healthy|Harbor status: healthy/i, + ); + + await recordCheck(summary, 'setup-harbor-success-actions', async () => ({ + ok: true, + createdIntegrationId, + integrationName, + ...actionResult, + snapshot: await captureSnapshot(page, 'setup-harbor-success-actions'), + })); + + return { + id: createdIntegrationId, + name: integrationName, + detailPath: `/setup/integrations/${createdIntegrationId}`, + }; +} + +async function createGitHubIntegration(page, summary, uniqueSuffix) { + await openSetupOnboarding(page); + await openTypedWizard(page, /\+ Add SCM/i); + await settle(page); + + const integrationName = `QA GitHub Fixture ${uniqueSuffix}`; + await page.getByLabel(/Endpoint/i).fill('http://github-app-fixture.stella-ops.local'); + await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/github#app-${uniqueSuffix}`); + await page.getByLabel(/Owner \/ Organization/i).fill('stellaops'); + await page.getByLabel(/GitHub App ID/i).fill('424242'); + await page.getByLabel(/Installation ID/i).fill('424243'); + + await advanceWizardToReview(page, { + scopeHeading: /Discovery Scope/i, + fillScope: async () => { + await page.getByLabel(/Repositories/i).fill('stellaops/demo'); + await page.getByLabel(/Branch Patterns/i).fill('main'); + }, + }); + + await page.getByLabel(/Integration Name/i).fill(integrationName); + await page.getByRole('button', { name: /Create Integration/i }).click({ timeout: 10_000 }); + await waitForDetailRoute(page); + + const createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null; + const pageText = await page.locator('body').innerText(); + + await recordCheck(summary, 'setup-github-create-and-detail-render', async () => ({ + ok: Boolean(createdIntegrationId) + && page.url().includes('/setup/integrations/') + && pageText.includes(integrationName) + && pageText.includes('http://github-app-fixture.stella-ops.local') + && pageText.includes('Configured via AuthRef'), + createdIntegrationId, + integrationName, + snapshot: await captureSnapshot(page, 'setup-github-create-and-detail-render'), + })); + + const actionResult = await testDetailActions( + page, + 'GitHub App', + /Connected as GitHub App: Stella QA GitHub App/i, + /Healthy|Rate limit:/i, + ); + + await recordCheck(summary, 'setup-github-success-actions', async () => ({ + ok: true, + createdIntegrationId, + integrationName, + ...actionResult, + snapshot: await captureSnapshot(page, 'setup-github-success-actions'), + })); + + return { + id: createdIntegrationId, + name: integrationName, + detailPath: `/setup/integrations/${createdIntegrationId}`, + }; +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + const page = await context.newPage(); + + const runtime = { + consoleErrors: [], + pageErrors: [], + responseErrors: [], + requestFailures: [], + }; + + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ page: page.url(), text: message.text() }); + } + }); + page.on('pageerror', (error) => { + runtime.pageErrors.push({ page: page.url(), message: error.message }); + }); + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url, + error: errorText, + }); + }); + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url, + }); + } + }); + + const summary = { + generatedAtUtc: new Date().toISOString(), + runtime, + checks: [], + cleanup: [], + }; + + const createdIntegrations = []; + + try { + await page.goto(scopedUrl('/setup/integrations'), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + + await recordCheck(summary, 'setup-integrations-entry', async () => { + const pageText = await page.locator('body').innerText(); + return { + ok: page.url().includes('/setup/integrations') + && pageText.includes('Registries') + && pageText.includes('SCM') + && await page.getByRole('button', { name: /\+ Add Integration/i }).isVisible(), + snapshot: await captureSnapshot(page, 'setup-integrations-entry'), + }; + }); + + const uniqueSuffix = Date.now().toString(); + + const harbor = await createHarborIntegration(page, summary, uniqueSuffix); + createdIntegrations.push(harbor); + await deleteIntegration(page, harbor.detailPath); + summary.cleanup.push({ + provider: 'Harbor', + integrationId: harbor.id, + ok: true, + finalUrl: page.url(), + }); + + const github = await createGitHubIntegration(page, summary, uniqueSuffix); + createdIntegrations.push(github); + await deleteIntegration(page, github.detailPath); + summary.cleanup.push({ + provider: 'GitHub App', + integrationId: github.id, + ok: true, + finalUrl: page.url(), + }); + + await recordCheck(summary, 'setup-integrations-return-after-cleanup', async () => ({ + ok: page.url().includes('/setup/integrations') + && await page.getByRole('button', { name: /\+ Add Integration/i }).isVisible(), + snapshot: await captureSnapshot(page, 'setup-integrations-return-after-cleanup'), + })); + } finally { + for (const integration of createdIntegrations.slice().reverse()) { + const alreadyDeleted = summary.cleanup.some((entry) => entry.integrationId === integration.id && entry.ok); + if (alreadyDeleted || !integration.id) { + continue; + } + + try { + await deleteIntegration(page, integration.detailPath); + summary.cleanup.push({ + provider: integration.name, + integrationId: integration.id, + ok: true, + finalUrl: page.url(), + }); + } catch (error) { + summary.cleanup.push({ + provider: integration.name, + integrationId: integration.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + summary.failedCheckCount = summary.checks.filter((check) => check.ok === false).length; + summary.runtimeIssueCount = + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.responseErrors.length + + runtime.requestFailures.length; + + await persistSummary(summary).catch(() => {}); + await browser.close().catch(() => {}); + } + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + process.stderr.write( + `[live-integrations-onboarding-success-fixtures-check] ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts index d1cf3ac09..ab76e9594 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts @@ -1,192 +1,98 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + import { IntegrationDetailComponent } from './integration-detail.component'; import { IntegrationService } from './integration.service'; -import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models'; +import { + HealthStatus, + IntegrationProvider, + IntegrationStatus, + IntegrationType, +} from './integration.models'; describe('IntegrationDetailComponent', () => { - let component: IntegrationDetailComponent; let fixture: ComponentFixture; - let httpMock: HttpTestingController; - - const mockIntegration: Integration = { - id: '1', - name: 'Harbor Registry', - type: IntegrationType.Registry, - provider: 'harbor', - status: IntegrationStatus.Active, - description: 'Main container registry', - tags: ['production'], - configuration: { - endpoint: 'https://harbor.example.com', - username: 'admin' - }, - createdAt: '2025-12-29T12:00:00Z', - updatedAt: '2025-12-29T12:00:00Z', - createdBy: 'admin', - lastHealthCheck: '2025-12-29T11:55:00Z', - healthStatus: 'healthy' - }; + let component: IntegrationDetailComponent; + let integrationService: jasmine.SpyObj; beforeEach(async () => { + integrationService = jasmine.createSpyObj('IntegrationService', ['get', 'testConnection', 'getHealth', 'delete']); + integrationService.get.and.returnValue(of({ + id: 'int-1', + name: 'Harbor Registry', + description: 'Main registry', + type: IntegrationType.Registry, + provider: IntegrationProvider.Harbor, + status: IntegrationStatus.Active, + endpoint: 'https://harbor.example.com', + hasAuth: true, + organizationId: 'platform', + lastHealthStatus: HealthStatus.Healthy, + lastHealthCheckAt: '2026-03-14T10:00:00Z', + createdAt: '2026-03-14T09:00:00Z', + updatedAt: '2026-03-14T10:00:00Z', + createdBy: 'demo-user', + updatedBy: 'demo-user', + tags: ['prod'], + })); + integrationService.testConnection.and.returnValue(of({ + integrationId: 'int-1', + success: false, + message: 'Connection failed: ENOTFOUND harbor.example.com', + details: { endpoint: 'https://harbor.example.com' }, + duration: '00:00:00.1000000', + testedAt: '2026-03-14T10:05:00Z', + })); + integrationService.getHealth.and.returnValue(of({ + integrationId: 'int-1', + status: HealthStatus.Unhealthy, + message: 'Health check failed: ENOTFOUND harbor.example.com', + details: { endpoint: 'https://harbor.example.com' }, + checkedAt: '2026-03-14T10:06:00Z', + duration: '00:00:00.1000000', + })); + await TestBed.configureTestingModule({ imports: [IntegrationDetailComponent], providers: [ - IntegrationService, - provideHttpClient(), - provideHttpClientTesting() - ] + provideRouter([]), + { provide: IntegrationService, useValue: integrationService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ integrationId: 'int-1' }), + }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationDetailComponent); component = fixture.componentInstance; - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load integration details when integrationId is set', () => { - component.integrationId = '1'; fixture.detectChanges(); - - const req = httpMock.expectOne('/api/v1/integrations/1'); - expect(req.request.method).toBe('GET'); - req.flush(mockIntegration); - - expect(component.integration).toEqual(mockIntegration); }); - it('should test connection successfully', fakeAsync(() => { - component.integration = mockIntegration; - fixture.detectChanges(); - - const testResult: ConnectionTestResult = { - success: true, - message: 'Connected successfully', - latencyMs: 45 - }; + it('loads canonical integration details and uses endpoint and health status fields', () => { + expect(component.integration?.id).toBe('int-1'); + expect(component.integration?.endpoint).toBe('https://harbor.example.com'); + expect(component.getHealthLabel(component.integration!.lastHealthStatus)).toBe('Healthy'); + }); + it('captures test connection results using backend message and duration fields', () => { component.testConnection(); - tick(); - const req = httpMock.expectOne('/api/v1/integrations/1/test-connection'); - expect(req.request.method).toBe('POST'); - req.flush(testResult); - - tick(); - - expect(component.connectionTestResult).toEqual(testResult); - expect(component.isTestingConnection).toBeFalse(); - })); - - it('should handle test connection failure', fakeAsync(() => { - component.integration = mockIntegration; - fixture.detectChanges(); - - const testResult: ConnectionTestResult = { - success: false, - message: 'Connection refused', - error: 'ECONNREFUSED' - }; - - component.testConnection(); - tick(); - - const req = httpMock.expectOne('/api/v1/integrations/1/test-connection'); - req.flush(testResult); - - tick(); - - expect(component.connectionTestResult?.success).toBeFalse(); - expect(component.connectionTestResult?.error).toBe('ECONNREFUSED'); - })); - - it('should enable integration', fakeAsync(() => { - const disabledIntegration = { ...mockIntegration, status: IntegrationStatus.Disabled }; - component.integration = disabledIntegration; - fixture.detectChanges(); - - component.enableIntegration(); - tick(); - - const req = httpMock.expectOne('/api/v1/integrations/1/enable'); - expect(req.request.method).toBe('POST'); - req.flush({ ...mockIntegration, status: IntegrationStatus.Active }); - - tick(); - - expect(component.integration?.status).toBe(IntegrationStatus.Active); - })); - - it('should disable integration', fakeAsync(() => { - component.integration = mockIntegration; - fixture.detectChanges(); - - component.disableIntegration(); - tick(); - - const req = httpMock.expectOne('/api/v1/integrations/1/disable'); - expect(req.request.method).toBe('POST'); - req.flush({ ...mockIntegration, status: IntegrationStatus.Disabled }); - - tick(); - - expect(component.integration?.status).toBe(IntegrationStatus.Disabled); - })); - - it('should display configuration fields', () => { - component.integration = mockIntegration; - fixture.detectChanges(); - - const configKeys = component.getConfigurationKeys(); - expect(configKeys).toContain('endpoint'); - expect(configKeys).toContain('username'); + expect(component.lastTestResult?.success).toBeFalse(); + expect(component.lastTestResult?.message).toContain('ENOTFOUND'); + expect(component.lastTestResult?.duration).toBe('00:00:00.1000000'); }); - it('should mask sensitive configuration values', () => { - component.integration = { - ...mockIntegration, - configuration: { - endpoint: 'https://harbor.example.com', - password: 'authref://vault/harbor#password' - } - }; - fixture.detectChanges(); + it('captures health results using checkedAt and message fields', () => { + component.checkHealth(); - expect(component.getDisplayValue('password', 'authref://vault/harbor#password')).toBe('••••••••'); - expect(component.getDisplayValue('endpoint', 'https://harbor.example.com')).toBe('https://harbor.example.com'); - }); - - it('should calculate health status correctly', () => { - component.integration = mockIntegration; - - expect(component.getHealthStatusClass()).toBe('status-healthy'); - - component.integration = { ...mockIntegration, healthStatus: 'unhealthy' }; - expect(component.getHealthStatusClass()).toBe('status-unhealthy'); - - component.integration = { ...mockIntegration, healthStatus: 'unknown' }; - expect(component.getHealthStatusClass()).toBe('status-unknown'); - }); - - it('should format last health check time', () => { - component.integration = mockIntegration; - - const formatted = component.formatLastHealthCheck(); - expect(formatted).toBeTruthy(); - expect(typeof formatted).toBe('string'); - }); - - it('should emit close event', () => { - const closeSpy = spyOn(component.closed, 'emit'); - component.close(); - expect(closeSpy).toHaveBeenCalled(); + expect(component.lastHealthResult?.status).toBe(HealthStatus.Unhealthy); + expect(component.lastHealthResult?.message).toContain('ENOTFOUND'); + expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index 3a485f80a..2d85e8015 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -5,10 +5,13 @@ import { timeout } from 'rxjs'; import { IntegrationService } from './integration.service'; import { integrationWorkspaceCommands } from './integration-route-context'; import { + HealthStatus, Integration, IntegrationHealthResponse, TestConnectionResponse, IntegrationStatus, + getHealthStatusColor, + getHealthStatusLabel, getIntegrationStatusLabel, getIntegrationStatusColor, getIntegrationTypeLabel, @@ -47,17 +50,17 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
- {{ integration.baseUrl || 'Not configured' }} + {{ integration.endpoint || 'Not configured' }}
- - {{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }} + + {{ getHealthLabel(integration.lastHealthStatus) }}
- {{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }} + {{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}