Widen scratch iteration 011 with fixture-backed integrations QA

This commit is contained in:
master
2026-03-14 03:11:45 +02:00
parent 3b1b7dad80
commit bd78523564
40 changed files with 3478 additions and 2173 deletions

View File

@@ -181,6 +181,11 @@ required notices or source offers.
- **License:** Apache-2.0 - **License:** Apache-2.0
- **Source:** https://github.com/moby/moby - **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 #### Kubernetes
- **License:** Apache-2.0 - **License:** Apache-2.0
- **Source:** https://github.com/kubernetes/kubernetes - **Source:** https://github.com/kubernetes/kubernetes

View File

@@ -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` | | 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` | | 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` | | 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 China compliance | See [China Compliance](#china-compliance-sm2sm3sm4) |
| Deploy with Russia compliance | See [Russia Compliance](#russia-compliance-gost) | | 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 | | File | Purpose |
|------|---------| |------|---------|
| `docker-compose.stella-ops.yml` | **Main stack**: PostgreSQL 18.1, Valkey 9.0.1, RustFS, Rekor v2, all StellaOps services | | `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.telemetry.yml` | **Observability**: OpenTelemetry collector, Prometheus, Tempo, Loki |
| `docker-compose.testing.yml` | **CI/Testing**: Test databases, mock services, Gitea for integration tests | | `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 | | `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 ## Regional Compliance Deployments
### China Compliance (SM2/SM3/SM4) ### China Compliance (SM2/SM3/SM4)

View File

@@ -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"

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -59,3 +59,5 @@
127.1.1.3 s3.stella-ops.local 127.1.1.3 s3.stella-ops.local
127.1.1.4 rekor.stella-ops.local 127.1.1.4 rekor.stella-ops.local
127.1.1.5 registry.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

View File

@@ -40,6 +40,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config
```powershell ```powershell
.\scripts\setup.ps1 # full setup .\scripts\setup.ps1 # full setup
.\scripts\setup.ps1 -InfraOnly # infrastructure only (PostgreSQL, Valkey, RustFS, Rekor, Zot) .\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:** **Linux / macOS:**
@@ -47,6 +48,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config
```bash ```bash
./scripts/setup.sh # full setup ./scripts/setup.sh # full setup
./scripts/setup.sh --infra-only # infrastructure only ./scripts/setup.sh --infra-only # infrastructure only
./scripts/setup.sh --qa-integration-fixtures # full setup plus Harbor/GitHub App QA fixtures
``` ```
The scripts will: 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`) 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) 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 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. 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. 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) - **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator)
- **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'` - **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'`

View File

@@ -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 -SkipBuild # skip .NET builds, build images and start platform
.\scripts\setup.ps1 -SkipImages # build .NET but skip Docker images .\scripts\setup.ps1 -SkipImages # build .NET but skip Docker images
.\scripts\setup.ps1 -ImagesOnly # only build 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:** **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-build # skip .NET builds
./scripts/setup.sh --skip-images # skip Docker image builds ./scripts/setup.sh --skip-images # skip Docker image builds
./scripts/setup.sh --images-only # only build Docker images ./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. 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) ### 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 ### Manual

View File

@@ -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.

View File

@@ -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.

View File

@@ -17,7 +17,7 @@ This document provides a comprehensive inventory of all third-party dependencies
| NuGet (Dev/Test) | ~50+ | MIT, Apache-2.0 | | NuGet (Dev/Test) | ~50+ | MIT, Apache-2.0 |
| npm (Runtime) | ~15 | MIT, Apache-2.0, ISC, 0BSD | | npm (Runtime) | ~15 | MIT, Apache-2.0, ISC, 0BSD |
| npm (Dev) | ~30+ | MIT, Apache-2.0 | | 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 ### 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 | | 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) | | 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 | | 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. | | OCI Registry | - | Varies | - | External | Harbor (Apache-2.0), Docker Hub, etc. |
| Kubernetes | ≥1.28 | Apache-2.0 | Apache-2.0 | Orchestration | Optional | | 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`) | | all-MiniLM-L6-v2 embedding model | - | Apache-2.0 | Apache-2.0 | Optional runtime asset | Local semantic embedding model for AdvisoryAI (`VectorEncoderType=onnx`) |

View File

@@ -96,6 +96,11 @@ public interface IIntegrationPlugin
- **Harbor** - Robot account authentication, project and repository enumeration - **Harbor** - Robot account authentication, project and repository enumeration
- **InMemory** - Deterministic test double for integration tests and offline development - **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 ## 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. - **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.

View File

@@ -8,7 +8,9 @@ param(
[string]$ImplId = (Get-Date).ToUniversalTime().ToString('yyyyMMdd'), [string]$ImplId = (Get-Date).ToUniversalTime().ToString('yyyyMMdd'),
[int]$StartingBatchId = 0 [int]$StartingBatchId = 0,
[bool]$QaIntegrationFixtures = $true
) )
Set-StrictMode -Version Latest 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: 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('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.') $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('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.') $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' 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' 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 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.Criteria12 = $true
$state.Criteria13 = $true $state.Criteria13 = $true
$state.Status1 = 'DONE' $state.Status1 = 'DONE'
$healthySummary = Get-HealthyContainerSummary $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' $state.Status2 = 'DOING'
Write-SprintState -State $state -Path $sprintPath Write-SprintState -State $state -Path $sprintPath

View File

@@ -13,13 +13,16 @@
Only build Docker images (skip infra start and .NET build). Only build Docker images (skip infra start and .NET build).
.PARAMETER SkipImages .PARAMETER SkipImages
Skip Docker image builds. Skip Docker image builds.
.PARAMETER QaIntegrationFixtures
Start the optional Harbor and GitHub App QA fixtures used for successful Integrations Hub onboarding checks.
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[switch]$SkipBuild, [switch]$SkipBuild,
[switch]$InfraOnly, [switch]$InfraOnly,
[switch]$ImagesOnly, [switch]$ImagesOnly,
[switch]$SkipImages [switch]$SkipImages,
[switch]$QaIntegrationFixtures
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -391,7 +394,7 @@ function Test-Prerequisites {
# ─── 2. Check and install hosts file ───────────────────────────────────── # ─── 2. Check and install hosts file ─────────────────────────────────────
function Test-HostsFile { 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' $hostsPath = 'C:\Windows\System32\drivers\etc\hosts'
$hostsSource = Join-Path $Root 'devops/compose/hosts.stellaops.local' $hostsSource = Join-Path $Root 'devops/compose/hosts.stellaops.local'
@@ -400,20 +403,49 @@ function Test-HostsFile {
return 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)) { if (-not (Test-Path $hostsSource)) {
Write-Warn "Hosts source file not found at $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 Write-Host ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' -ForegroundColor Yellow
return 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 # Check if running as Administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
@@ -424,9 +456,9 @@ function Test-HostsFile {
Write-Host '' Write-Host ''
$answer = Read-Host ' Add entries to hosts file now? (Y/n)' $answer = Read-Host ' Add entries to hosts file now? (Y/n)'
if ($answer -eq '' -or $answer -match '^[Yy]') { if ($answer -eq '' -or $answer -match '^[Yy]') {
$hostsBlock = Get-Content $hostsSource -Raw Add-Content -Path $hostsPath -Value ''
Add-Content -Path $hostsPath -Value "`n$hostsBlock" Add-Content -Path $hostsPath -Value ($missingLines -join [Environment]::NewLine)
Write-Ok 'Hosts entries added successfully' Write-Ok "Added $($missingLines.Count) missing host entry line(s) successfully"
} else { } else {
Write-Warn 'Skipped. Add them manually before accessing the platform.' Write-Warn 'Skipped. Add them manually before accessing the platform.'
Write-Host " Copy from: $hostsSource" -ForegroundColor Yellow Write-Host " Copy from: $hostsSource" -ForegroundColor Yellow
@@ -597,6 +629,27 @@ function Start-Platform {
-restartAfterSeconds 45) -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) { function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) {
for ($attempt = 1; $attempt -le $attempts; $attempt++) { for ($attempt = 1; $attempt -le $attempts; $attempt++) {
$statusCode = $null $statusCode = $null
@@ -770,6 +823,24 @@ function Test-Smoke {
$hasBlockingFailures = $true $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 (-not $InfraOnly) {
if (Test-FrontdoorBootstrap) { if (Test-FrontdoorBootstrap) {
Write-Ok 'Frontdoor bootstrap path is ready for first-user sign-in' Write-Ok 'Frontdoor bootstrap path is ready for first-user sign-in'
@@ -794,8 +865,15 @@ function Test-Smoke {
@('docker-compose.stella-ops.yml') @('docker-compose.stella-ops.yml')
} }
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
if (-not ($composeFiles | Where-Object { Test-Path $_ })) { if (-not ($composeFiles | Where-Object { Test-Path $_ })) {
$composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.yml') $composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.yml')
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
} }
$totalContainers = 0 $totalContainers = 0
@@ -902,6 +980,9 @@ Initialize-EnvFile
if ($InfraOnly) { if ($InfraOnly) {
Start-Infrastructure Start-Infrastructure
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$infraSmokeFailed = Test-Smoke $infraSmokeFailed = Test-Smoke
if ($infraSmokeFailed) { if ($infraSmokeFailed) {
Write-Fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.' 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 Start-Platform
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$platformSmokeFailed = Test-Smoke $platformSmokeFailed = Test-Smoke
if ($platformSmokeFailed) { if ($platformSmokeFailed) {
Write-Fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.' 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 "`n=============================================" -ForegroundColor Green
Write-Host ' Setup complete!' -ForegroundColor Green Write-Host ' Setup complete!' -ForegroundColor Green
Write-Host ' Platform: https://stella-ops.local' -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 ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green
Write-Host '=============================================' -ForegroundColor Green Write-Host '=============================================' -ForegroundColor Green
exit 0 exit 0

View File

@@ -11,6 +11,7 @@ SKIP_BUILD=false
INFRA_ONLY=false INFRA_ONLY=false
IMAGES_ONLY=false IMAGES_ONLY=false
SKIP_IMAGES=false SKIP_IMAGES=false
QA_INTEGRATION_FIXTURES=false
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -18,8 +19,9 @@ for arg in "$@"; do
--infra-only) INFRA_ONLY=true ;; --infra-only) INFRA_ONLY=true ;;
--images-only) IMAGES_ONLY=true ;; --images-only) IMAGES_ONLY=true ;;
--skip-images) SKIP_IMAGES=true ;; --skip-images) SKIP_IMAGES=true ;;
--qa-integration-fixtures) QA_INTEGRATION_FIXTURES=true ;;
-h|--help) -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 exit 0
;; ;;
*) echo "Unknown flag: $arg" >&2; exit 1 ;; *) echo "Unknown flag: $arg" >&2; exit 1 ;;
@@ -326,16 +328,9 @@ check_prerequisites() {
# ─── 2. Check and install hosts file ───────────────────────────────────── # ─── 2. Check and install hosts file ─────────────────────────────────────
check_hosts() { 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" 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 if [[ ! -f "$hosts_source" ]]; then
warn "Hosts source file not found at $hosts_source" warn "Hosts source file not found at $hosts_source"
echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2'
@@ -343,6 +338,42 @@ check_hosts() {
return return
fi 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 ''
echo ' Stella Ops needs ~50 hosts file entries for local development.' echo ' Stella Ops needs ~50 hosts file entries for local development.'
echo " Source: devops/compose/hosts.stellaops.local" echo " Source: devops/compose/hosts.stellaops.local"
@@ -353,21 +384,21 @@ check_hosts() {
if [[ -z "$answer" || "$answer" =~ ^[Yy] ]]; then if [[ -z "$answer" || "$answer" =~ ^[Yy] ]]; then
if [[ "$(id -u)" -eq 0 ]]; then if [[ "$(id -u)" -eq 0 ]]; then
printf '\n' >> /etc/hosts printf '\n' >> /etc/hosts
cat "$hosts_source" >> /etc/hosts printf '%s\n' "${missing_lines[@]}" >> /etc/hosts
ok 'Hosts entries added successfully' ok "Added ${#missing_lines[@]} missing host entry line(s) successfully"
else else
echo '' echo ''
echo ' Adding hosts entries requires sudo...' echo ' Adding hosts entries requires sudo...'
if sudo sh -c "printf '\n' >> /etc/hosts && cat '$hosts_source' >> /etc/hosts"; then if printf '%s\n' "${missing_lines[@]}" | sudo tee -a /etc/hosts >/dev/null; then
ok 'Hosts entries added successfully' ok "Added ${#missing_lines[@]} missing host entry line(s) successfully"
else else
warn 'Failed to add hosts entries. Add them manually:' 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
fi fi
else else
warn 'Skipped. Add them manually before accessing the platform:' warn 'Skipped. Add them manually before accessing the platform:'
echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'" printf ' %s\n' "${missing_lines[@]}"
fi 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 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() { http_status() {
local url="$1" local url="$1"
local attempts="${2:-6}" local attempts="${2:-6}"
@@ -597,6 +637,25 @@ smoke_test() {
has_blocking_failures=true has_blocking_failures=true
fi 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 [[ "$INFRA_ONLY" != "true" ]]; then
if ! frontdoor_bootstrap_ready; then if ! frontdoor_bootstrap_ready; then
has_blocking_failures=true has_blocking_failures=true
@@ -614,8 +673,19 @@ smoke_test() {
local total=0 local total=0
local healthy=0 local healthy=0
local unhealthy_names="" 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 [[ ! -f "$cf" ]] && continue
while IFS= read -r line; do while IFS= read -r line; do
[[ -z "$line" ]] && continue [[ -z "$line" ]] && continue
@@ -676,6 +746,9 @@ ensure_env
start_infra start_infra
if [[ "$INFRA_ONLY" == "true" ]]; then if [[ "$INFRA_ONLY" == "true" ]]; then
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
start_qa_integration_fixtures
fi
if ! smoke_test; then if ! smoke_test; then
fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.' fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1 exit 1
@@ -698,6 +771,9 @@ if [[ "$SKIP_IMAGES" != "true" ]]; then
fi fi
start_platform start_platform
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
start_qa_integration_fixtures
fi
if ! smoke_test; then if ! smoke_test; then
fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.' fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1 exit 1
@@ -707,6 +783,10 @@ echo ''
echo '=============================================' echo '============================================='
echo ' Setup complete!' echo ' Setup complete!'
echo ' Platform: https://stella-ops.local' 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 ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md'
echo '=============================================' echo '============================================='
exit 0 exit 0

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using static StellaOps.Localization.T; using static StellaOps.Localization.T;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Integrations.Contracts; using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Contracts.AiCodeGuard; using StellaOps.Integrations.Contracts.AiCodeGuard;
@@ -59,10 +61,11 @@ public static class IntegrationEndpoints
// Get integration by ID // Get integration by ID
group.MapGet("/{id:guid}", async ( group.MapGet("/{id:guid}", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id, Guid id,
CancellationToken cancellationToken) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}) })
.RequireAuthorization(IntegrationPolicies.Read) .RequireAuthorization(IntegrationPolicies.Read)
@@ -73,10 +76,12 @@ public static class IntegrationEndpoints
group.MapPost("/", async ( group.MapPost("/", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor, [FromServices] IStellaOpsTenantAccessor tenantAccessor,
HttpContext httpContext,
[FromBody] CreateIntegrationRequest request, [FromBody] CreateIntegrationRequest request,
CancellationToken cancellationToken) => 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); return Results.Created($"/api/v1/integrations/{result.Id}", result);
}) })
.RequireAuthorization(IntegrationPolicies.Write) .RequireAuthorization(IntegrationPolicies.Write)
@@ -87,11 +92,13 @@ public static class IntegrationEndpoints
group.MapPut("/{id:guid}", async ( group.MapPut("/{id:guid}", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor, [FromServices] IStellaOpsTenantAccessor tenantAccessor,
HttpContext httpContext,
Guid id, Guid id,
[FromBody] UpdateIntegrationRequest request, [FromBody] UpdateIntegrationRequest request,
CancellationToken cancellationToken) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}) })
.RequireAuthorization(IntegrationPolicies.Write) .RequireAuthorization(IntegrationPolicies.Write)
@@ -102,10 +109,12 @@ public static class IntegrationEndpoints
group.MapDelete("/{id:guid}", async ( group.MapDelete("/{id:guid}", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor, [FromServices] IStellaOpsTenantAccessor tenantAccessor,
HttpContext httpContext,
Guid id, Guid id,
CancellationToken cancellationToken) => 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(); return result ? Results.NoContent() : Results.NotFound();
}) })
.RequireAuthorization(IntegrationPolicies.Write) .RequireAuthorization(IntegrationPolicies.Write)
@@ -116,10 +125,12 @@ public static class IntegrationEndpoints
group.MapPost("/{id:guid}/test", async ( group.MapPost("/{id:guid}/test", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor, [FromServices] IStellaOpsTenantAccessor tenantAccessor,
HttpContext httpContext,
Guid id, Guid id,
CancellationToken cancellationToken) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}) })
.RequireAuthorization(IntegrationPolicies.Operate) .RequireAuthorization(IntegrationPolicies.Operate)
@@ -129,10 +140,11 @@ public static class IntegrationEndpoints
// Health check // Health check
group.MapGet("/{id:guid}/health", async ( group.MapGet("/{id:guid}/health", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id, Guid id,
CancellationToken cancellationToken) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}) })
.RequireAuthorization(IntegrationPolicies.Read) .RequireAuthorization(IntegrationPolicies.Read)
@@ -142,10 +154,11 @@ public static class IntegrationEndpoints
// Impact map // Impact map
group.MapGet("/{id:guid}/impact", async ( group.MapGet("/{id:guid}/impact", async (
[FromServices] IntegrationService service, [FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id, Guid id,
CancellationToken cancellationToken) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}) })
.RequireAuthorization(IntegrationPolicies.Read) .RequireAuthorization(IntegrationPolicies.Read)
@@ -162,4 +175,12 @@ public static class IntegrationEndpoints
.WithName("GetSupportedProviders") .WithName("GetSupportedProviders")
.WithDescription(_t("integrations.integration.get_providers_description")); .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;
}
} }

View File

@@ -38,7 +38,7 @@ public sealed class IntegrationService
_logger = logger; _logger = logger;
} }
public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default) public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? tenantId, string? userId, CancellationToken cancellationToken = default)
{ {
var now = _timeProvider.GetUtcNow(); var now = _timeProvider.GetUtcNow();
var integration = new Integration var integration = new Integration
@@ -78,9 +78,9 @@ public sealed class IntegrationService
return MapToResponse(created); return MapToResponse(created);
} }
public async Task<IntegrationResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) public async Task<IntegrationResponse?> 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); return integration is null ? null : MapToResponse(integration);
} }
@@ -110,9 +110,9 @@ public sealed class IntegrationService
totalPages); totalPages);
} }
public async Task<IntegrationResponse?> UpdateAsync(Guid id, UpdateIntegrationRequest request, string? userId, CancellationToken cancellationToken = default) public async Task<IntegrationResponse?> 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; if (integration is null) return null;
var oldStatus = integration.Status; var oldStatus = integration.Status;
@@ -153,9 +153,9 @@ public sealed class IntegrationService
return MapToResponse(updated); return MapToResponse(updated);
} }
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default) public async Task<bool> 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; if (integration is null) return false;
await _repository.DeleteAsync(id, cancellationToken); await _repository.DeleteAsync(id, cancellationToken);
@@ -172,9 +172,9 @@ public sealed class IntegrationService
return true; return true;
} }
public async Task<TestConnectionResponse?> TestConnectionAsync(Guid id, string? userId, CancellationToken cancellationToken = default) public async Task<TestConnectionResponse?> 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; if (integration is null) return null;
var plugin = _pluginLoader.GetByProvider(integration.Provider); var plugin = _pluginLoader.GetByProvider(integration.Provider);
@@ -227,9 +227,9 @@ public sealed class IntegrationService
endTime); endTime);
} }
public async Task<HealthCheckResponse?> CheckHealthAsync(Guid id, CancellationToken cancellationToken = default) public async Task<HealthCheckResponse?> 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; if (integration is null) return null;
var plugin = _pluginLoader.GetByProvider(integration.Provider); var plugin = _pluginLoader.GetByProvider(integration.Provider);
@@ -269,9 +269,9 @@ public sealed class IntegrationService
result.Duration); result.Duration);
} }
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, CancellationToken cancellationToken = default) public async Task<IntegrationImpactResponse?> 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) if (integration is null)
{ {
return null; return null;
@@ -302,6 +302,27 @@ public sealed class IntegrationService
p.Provider)).ToList(); p.Provider)).ToList();
} }
private async Task<Integration?> 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<ImpactedWorkflow> BuildImpactedWorkflows(Integration integration) private static IReadOnlyList<ImpactedWorkflow> BuildImpactedWorkflows(Integration integration)
{ {
var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived; var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived;

View File

@@ -41,7 +41,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
try try
{ {
// Call GitHub API to verify authentication // 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; var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@@ -98,7 +98,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
try try
{ {
// Check GitHub API status // 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; var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@@ -151,9 +151,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
private static HttpClient CreateHttpClient(IntegrationConfig config) private static HttpClient CreateHttpClient(IntegrationConfig config)
{ {
var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com" var baseUrl = ResolveBaseUrl(config.Endpoint);
? "https://api.github.com"
: config.Endpoint.TrimEnd('/') + "/api/v3";
var client = new HttpClient var client = new HttpClient
{ {
@@ -174,6 +172,22 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
return client; 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true

View File

@@ -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;
/// <summary>
/// Focused transport-level tests for GitHubAppConnectorPlugin route construction.
/// </summary>
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<string, object>
{
["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<string> _requestPathTask;
private LoopbackHttpFixture(Func<string, HttpResponse> 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<string, HttpResponse> responder) => new(responder);
public Task<string> WaitForPathAsync() => _requestPathTask;
public void Dispose()
{
try
{
_listener.Stop();
}
catch
{
}
}
private async Task<string> HandleSingleRequestAsync(Func<string, HttpResponse> 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);
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" /> <ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" /> <ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" /> <ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -30,6 +30,42 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationI
_client = factory.CreateClient(); _client = factory.CreateClient();
} }
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateThenListEndpoint_ReturnsCreatedItemForCurrentTenant()
{
var createRequest = new CreateIntegrationRequest(
Name: $"Tenant Harbor {Guid.NewGuid():N}",
Description: "Registry integration",
Type: IntegrationType.Registry,
Provider: IntegrationProvider.InMemory,
Endpoint: "http://inmemory.local",
AuthRefUri: "authref://vault/inmemory#token",
OrganizationId: "tenant-scope",
ExtendedConfig: new Dictionary<string, object> { ["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<IntegrationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal("test-user", created!.CreatedBy);
var list = await _client.GetFromJsonAsync<PagedIntegrationsResponse>(
$"/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)] [Trait("Category", TestCategories.Integration)]
[Fact] [Fact]
public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap() public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap()
@@ -220,7 +256,41 @@ internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
{ {
lock (_gate) lock (_gate)
{ {
return Task.FromResult(_items.Values.Count(item => query.IncludeDeleted || !item.IsDeleted)); IEnumerable<Integration> 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());
} }
} }

View File

@@ -9,7 +9,7 @@ using Xunit;
namespace StellaOps.Integrations.Tests; namespace StellaOps.Integrations.Tests;
public class IntegrationServiceTests public sealed class IntegrationServiceTests
{ {
private readonly Mock<IIntegrationRepository> _repositoryMock; private readonly Mock<IIntegrationRepository> _repositoryMock;
private readonly Mock<IIntegrationEventPublisher> _eventPublisherMock; private readonly Mock<IIntegrationEventPublisher> _eventPublisherMock;
@@ -38,9 +38,8 @@ public class IntegrationServiceTests
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task CreateAsync_WithValidRequest_CreatesIntegration() public async Task CreateAsync_WithValidRequest_PersistsTenantAndActor()
{ {
// Arrange
var request = new CreateIntegrationRequest( var request = new CreateIntegrationRequest(
Name: "Test Registry", Name: "Test Registry",
Description: "Test description", Description: "Test description",
@@ -48,55 +47,43 @@ public class IntegrationServiceTests
Provider: IntegrationProvider.Harbor, Provider: IntegrationProvider.Harbor,
Endpoint: "https://harbor.example.com", Endpoint: "https://harbor.example.com",
AuthRefUri: "authref://vault/harbor#credentials", AuthRefUri: "authref://vault/harbor#credentials",
OrganizationId: "myorg", OrganizationId: "platform",
ExtendedConfig: null, ExtendedConfig: null,
Tags: ["test", "dev"]); Tags: ["test", "dev"]);
_repositoryMock _repositoryMock
.Setup(r => r.CreateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>())) .Setup(r => r.CreateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i)); .Returns<Integration, CancellationToken>((integration, _) => Task.FromResult(integration));
// Act var result = await _service.CreateAsync(request, "tenant-1", "test-user");
var result = await _service.CreateAsync(request, "test-user", "tenant-1");
// Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.Name.Should().Be("Test Registry"); 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.Endpoint.Should().Be("https://harbor.example.com");
result.CreatedBy.Should().Be("test-user");
result.UpdatedBy.Should().Be("test-user");
_repositoryMock.Verify(r => r.CreateAsync( _repositoryMock.Verify(
It.IsAny<Integration>(), r => r.CreateAsync(
It.IsAny<CancellationToken>()), Times.Once); It.Is<Integration>(integration =>
integration.TenantId == "tenant-1" &&
_eventPublisherMock.Verify(e => e.PublishAsync( integration.CreatedBy == "test-user" &&
It.IsAny<IntegrationCreatedEvent>(), integration.UpdatedBy == "test-user"),
It.IsAny<CancellationToken>()), Times.Once); It.IsAny<CancellationToken>()),
Times.Once);
_auditLoggerMock.Verify(a => a.LogAsync(
"integration.created",
It.IsAny<Guid>(),
"test-user",
It.IsAny<object>(),
It.IsAny<CancellationToken>()), Times.Once);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task GetByIdAsync_WithExistingId_ReturnsIntegration() public async Task GetByIdAsync_WithMatchingTenant_ReturnsIntegration()
{ {
// Arrange
var integration = CreateTestIntegration(); var integration = CreateTestIntegration();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration); .ReturnsAsync(integration);
// Act var result = await _service.GetByIdAsync(integration.Id, "tenant-1");
var result = await _service.GetByIdAsync(integration.Id);
// Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.Id.Should().Be(integration.Id); result!.Id.Should().Be(integration.Id);
result.Name.Should().Be(integration.Name); result.Name.Should().Be(integration.Name);
@@ -104,61 +91,56 @@ public class IntegrationServiceTests
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task GetByIdAsync_WithNonExistingId_ReturnsNull() public async Task GetByIdAsync_WithTenantMismatch_ReturnsNull()
{ {
// Arrange var integration = CreateTestIntegration(tenantId: "tenant-a");
var id = Guid.NewGuid();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null); .ReturnsAsync(integration);
// Act var result = await _service.GetByIdAsync(integration.Id, "tenant-b");
var result = await _service.GetByIdAsync(id);
// Assert
result.Should().BeNull(); result.Should().BeNull();
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task ListAsync_WithFilters_ReturnsFilteredResults() public async Task ListAsync_WithFilters_ScopesRepositoryQueryToTenant()
{ {
// Arrange
var integrations = new[] var integrations = new[]
{ {
CreateTestIntegration(type: IntegrationType.Registry), CreateTestIntegration(type: IntegrationType.Registry),
CreateTestIntegration(type: IntegrationType.Registry), CreateTestIntegration(type: IntegrationType.Registry),
CreateTestIntegration(type: IntegrationType.Scm) CreateTestIntegration(type: IntegrationType.Scm),
}; };
_repositoryMock _repositoryMock
.Setup(r => r.GetAllAsync( .Setup(r => r.GetAllAsync(
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry), It.Is<IntegrationQuery>(query =>
query.Type == IntegrationType.Registry &&
query.TenantId == "tenant-1"),
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.ReturnsAsync(integrations.Where(i => i.Type == IntegrationType.Registry).ToList()); .ReturnsAsync(integrations.Where(i => i.Type == IntegrationType.Registry).ToList());
_repositoryMock _repositoryMock
.Setup(r => r.CountAsync( .Setup(r => r.CountAsync(
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry), It.Is<IntegrationQuery>(query =>
query.Type == IntegrationType.Registry &&
query.TenantId == "tenant-1"),
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.ReturnsAsync(2); .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().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); result.TotalCount.Should().Be(2);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task UpdateAsync_WithExistingIntegration_UpdatesAndPublishesEvent() public async Task UpdateAsync_WithMatchingTenant_UpdatesAndPublishesEvent()
{ {
// Arrange
var integration = CreateTestIntegration(); var integration = CreateTestIntegration();
var request = new UpdateIntegrationRequest( var request = new UpdateIntegrationRequest(
Name: "Updated Name", Name: "Updated Name",
@@ -175,49 +157,49 @@ public class IntegrationServiceTests
.ReturnsAsync(integration); .ReturnsAsync(integration);
_repositoryMock _repositoryMock
.Setup(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>())) .Setup(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i)); .Returns<Integration, CancellationToken>((updated, _) => Task.FromResult(updated));
// Act var result = await _service.UpdateAsync(integration.Id, request, "tenant-1", "test-user");
var result = await _service.UpdateAsync(integration.Id, request, "test-user");
// Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.Name.Should().Be("Updated Name"); result!.Name.Should().Be("Updated Name");
result.Description.Should().Be("Updated description"); result.Description.Should().Be("Updated description");
result.Endpoint.Should().Be("https://updated.example.com"); result.Endpoint.Should().Be("https://updated.example.com");
_eventPublisherMock.Verify(e => e.PublishAsync( _eventPublisherMock.Verify(
It.IsAny<IntegrationUpdatedEvent>(), publisher => publisher.PublishAsync(It.IsAny<IntegrationUpdatedEvent>(), It.IsAny<CancellationToken>()),
It.IsAny<CancellationToken>()), Times.Once); Times.Once);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task UpdateAsync_WithNonExistingIntegration_ReturnsNull() public async Task UpdateAsync_WithTenantMismatch_ReturnsNullWithoutMutation()
{ {
// Arrange var integration = CreateTestIntegration(tenantId: "tenant-a");
var id = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null);
var request = new UpdateIntegrationRequest( var request = new UpdateIntegrationRequest(
Name: "Updated", Description: null, Endpoint: null, Name: "Updated",
AuthRefUri: null, OrganizationId: null, ExtendedConfig: null, Description: null,
Tags: null, Status: null); Endpoint: null,
AuthRefUri: null,
OrganizationId: null,
ExtendedConfig: null,
Tags: null,
Status: null);
// Act _repositoryMock
var result = await _service.UpdateAsync(id, request, "test-user"); .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
var result = await _service.UpdateAsync(integration.Id, request, "tenant-b", "test-user");
// Assert
result.Should().BeNull(); result.Should().BeNull();
_repositoryMock.Verify(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task DeleteAsync_WithExistingIntegration_DeletesAndPublishesEvent() public async Task DeleteAsync_WithMatchingTenant_DeletesAndPublishesEvent()
{ {
// Arrange
var integration = CreateTestIntegration(); var integration = CreateTestIntegration();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
@@ -226,51 +208,38 @@ public class IntegrationServiceTests
.Setup(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
// Act var result = await _service.DeleteAsync(integration.Id, "tenant-1", "test-user");
var result = await _service.DeleteAsync(integration.Id, "test-user");
// Assert
result.Should().BeTrue(); result.Should().BeTrue();
_repositoryMock.Verify(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()), Times.Once); _repositoryMock.Verify(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()), Times.Once);
_eventPublisherMock.Verify(e => e.PublishAsync(
It.IsAny<IntegrationDeletedEvent>(),
It.IsAny<CancellationToken>()), Times.Once);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task DeleteAsync_WithNonExistingIntegration_ReturnsFalse() public async Task DeleteAsync_WithTenantMismatch_ReturnsFalseWithoutDelete()
{ {
// Arrange var integration = CreateTestIntegration(tenantId: "tenant-a");
var id = Guid.NewGuid();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null); .ReturnsAsync(integration);
// Act var result = await _service.DeleteAsync(integration.Id, "tenant-b", "test-user");
var result = await _service.DeleteAsync(id, "test-user");
// Assert
result.Should().BeFalse(); result.Should().BeFalse();
_repositoryMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResult() public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResultForMatchingTenant()
{ {
// Arrange
var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor); var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor);
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration); .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.Should().NotBeNull();
result!.Success.Should().BeFalse(); result!.Success.Should().BeFalse();
result.Message.Should().Contain("No connector plugin"); result.Message.Should().Contain("No connector plugin");
@@ -278,66 +247,65 @@ public class IntegrationServiceTests
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task TestConnectionAsync_WithNonExistingIntegration_ReturnsNull() public async Task TestConnectionAsync_WithTenantMismatch_ReturnsNull()
{ {
// Arrange var integration = CreateTestIntegration(tenantId: "tenant-a");
var id = Guid.NewGuid();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null); .ReturnsAsync(integration);
// Act var result = await _service.TestConnectionAsync(integration.Id, "tenant-b", "test-user");
var result = await _service.TestConnectionAsync(id, "test-user");
// Assert
result.Should().BeNull(); result.Should().BeNull();
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatus() public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatusForMatchingTenant()
{ {
// Arrange
var integration = CreateTestIntegration(); var integration = CreateTestIntegration();
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration); .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.Should().NotBeNull();
result!.Status.Should().Be(HealthStatus.Unknown); result!.Status.Should().Be(HealthStatus.Unknown);
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty() public async Task CheckHealthAsync_WithTenantMismatch_ReturnsNull()
{ {
// Act var integration = CreateTestIntegration(tenantId: "tenant-a");
var result = _service.GetSupportedProviders(); _repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
// Assert var result = await _service.CheckHealthAsync(integration.Id, "tenant-b");
result.Should().BeEmpty();
result.Should().BeNull();
} }
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
[Fact] [Fact]
public async Task GetImpactAsync_WithNonExistingIntegration_ReturnsNull() public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty()
{ {
// Arrange _service.GetSupportedProviders().Should().BeEmpty();
var id = Guid.NewGuid(); }
[Trait("Category", "Unit")]
[Fact]
public async Task GetImpactAsync_WithTenantMismatch_ReturnsNull()
{
var integration = CreateTestIntegration(tenantId: "tenant-a");
_repositoryMock _repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null); .ReturnsAsync(integration);
// Act var result = await _service.GetImpactAsync(integration.Id, "tenant-b");
var result = await _service.GetImpactAsync(id);
// Assert
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -345,7 +313,6 @@ public class IntegrationServiceTests
[Fact] [Fact]
public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity() public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity()
{ {
// Arrange
var integration = CreateTestIntegration( var integration = CreateTestIntegration(
type: IntegrationType.FeedMirror, type: IntegrationType.FeedMirror,
provider: IntegrationProvider.NvdMirror); provider: IntegrationProvider.NvdMirror);
@@ -356,10 +323,8 @@ public class IntegrationServiceTests
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>())) .Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration); .ReturnsAsync(integration);
// Act var result = await _service.GetImpactAsync(integration.Id, "tenant-1");
var result = await _service.GetImpactAsync(integration.Id);
// Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.Severity.Should().Be("high"); result!.Severity.Should().Be("high");
result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount); result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount);
@@ -370,7 +335,8 @@ public class IntegrationServiceTests
private static Integration CreateTestIntegration( private static Integration CreateTestIntegration(
IntegrationType type = IntegrationType.Registry, IntegrationType type = IntegrationType.Registry,
IntegrationProvider provider = IntegrationProvider.Harbor) IntegrationProvider provider = IntegrationProvider.Harbor,
string tenantId = "tenant-1")
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
return new Integration return new Integration
@@ -382,10 +348,12 @@ public class IntegrationServiceTests
Status = IntegrationStatus.Active, Status = IntegrationStatus.Active,
Endpoint = "https://example.com", Endpoint = "https://example.com",
Description = "Test description", Description = "Test description",
TenantId = tenantId,
Tags = ["test"], Tags = ["test"],
CreatedBy = "test-user", CreatedBy = "test-user",
UpdatedBy = "test-user",
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now,
}; };
} }
} }

View File

@@ -37,6 +37,16 @@ const suites = [
script: 'live-integrations-action-sweep.mjs', script: 'live-integrations-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'), 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', name: 'setup-topology-action-sweep',
script: 'live-setup-topology-action-sweep.mjs', script: 'live-setup-topology-action-sweep.mjs',

View File

@@ -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&regions=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;
});

View File

@@ -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&regions=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;
});

View File

@@ -1,192 +1,98 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { of } from 'rxjs';
import { IntegrationDetailComponent } from './integration-detail.component'; import { IntegrationDetailComponent } from './integration-detail.component';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models'; import {
HealthStatus,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
} from './integration.models';
describe('IntegrationDetailComponent', () => { describe('IntegrationDetailComponent', () => {
let component: IntegrationDetailComponent;
let fixture: ComponentFixture<IntegrationDetailComponent>; let fixture: ComponentFixture<IntegrationDetailComponent>;
let httpMock: HttpTestingController; let component: IntegrationDetailComponent;
let integrationService: jasmine.SpyObj<IntegrationService>;
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'
};
beforeEach(async () => { beforeEach(async () => {
integrationService = jasmine.createSpyObj<IntegrationService>('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({ await TestBed.configureTestingModule({
imports: [IntegrationDetailComponent], imports: [IntegrationDetailComponent],
providers: [ providers: [
IntegrationService, provideRouter([]),
provideHttpClient(), { provide: IntegrationService, useValue: integrationService },
provideHttpClientTesting() {
] provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ integrationId: 'int-1' }),
},
},
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(IntegrationDetailComponent); fixture = TestBed.createComponent(IntegrationDetailComponent);
component = fixture.componentInstance; 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(); 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(() => { it('loads canonical integration details and uses endpoint and health status fields', () => {
component.integration = mockIntegration; expect(component.integration?.id).toBe('int-1');
fixture.detectChanges(); expect(component.integration?.endpoint).toBe('https://harbor.example.com');
expect(component.getHealthLabel(component.integration!.lastHealthStatus)).toBe('Healthy');
const testResult: ConnectionTestResult = { });
success: true,
message: 'Connected successfully',
latencyMs: 45
};
it('captures test connection results using backend message and duration fields', () => {
component.testConnection(); component.testConnection();
tick();
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection'); expect(component.lastTestResult?.success).toBeFalse();
expect(req.request.method).toBe('POST'); expect(component.lastTestResult?.message).toContain('ENOTFOUND');
req.flush(testResult); expect(component.lastTestResult?.duration).toBe('00:00:00.1000000');
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');
}); });
it('should mask sensitive configuration values', () => { it('captures health results using checkedAt and message fields', () => {
component.integration = { component.checkHealth();
...mockIntegration,
configuration: {
endpoint: 'https://harbor.example.com',
password: 'authref://vault/harbor#password'
}
};
fixture.detectChanges();
expect(component.getDisplayValue('password', 'authref://vault/harbor#password')).toBe('••••••••'); expect(component.lastHealthResult?.status).toBe(HealthStatus.Unhealthy);
expect(component.getDisplayValue('endpoint', 'https://harbor.example.com')).toBe('https://harbor.example.com'); expect(component.lastHealthResult?.message).toContain('ENOTFOUND');
}); expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z');
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();
}); });
}); });

View File

@@ -5,10 +5,13 @@ import { timeout } from 'rxjs';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { integrationWorkspaceCommands } from './integration-route-context'; import { integrationWorkspaceCommands } from './integration-route-context';
import { import {
HealthStatus,
Integration, Integration,
IntegrationHealthResponse, IntegrationHealthResponse,
TestConnectionResponse, TestConnectionResponse,
IntegrationStatus, IntegrationStatus,
getHealthStatusColor,
getHealthStatusLabel,
getIntegrationStatusLabel, getIntegrationStatusLabel,
getIntegrationStatusColor, getIntegrationStatusColor,
getIntegrationTypeLabel, getIntegrationTypeLabel,
@@ -47,17 +50,17 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
</div> </div>
<div class="summary-item"> <div class="summary-item">
<label>Endpoint</label> <label>Endpoint</label>
<span>{{ integration.baseUrl || 'Not configured' }}</span> <span>{{ integration.endpoint || 'Not configured' }}</span>
</div> </div>
<div class="summary-item"> <div class="summary-item">
<label>Health</label> <label>Health</label>
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')"> <span [class]="'health-badge health-' + getHealthColor(integration.lastHealthStatus)">
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }} {{ getHealthLabel(integration.lastHealthStatus) }}
</span> </span>
</div> </div>
<div class="summary-item"> <div class="summary-item">
<label>Last Checked</label> <label>Last Checked</label>
<span>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</span> <span>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}</span>
</div> </div>
</section> </section>
<nav class="detail-tabs"> <nav class="detail-tabs">
@@ -80,24 +83,24 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
} }
<h3>Configuration</h3> <h3>Configuration</h3>
<dl class="config-list"> <dl class="config-list">
<dt>Tenant</dt> <dt>Organization</dt>
<dd>{{ integration.tenantId || 'Not set' }}</dd> <dd>{{ integration.organizationId || 'Not set' }}</dd>
<dt>Has Auth</dt> <dt>Has Auth</dt>
<dd>{{ integration.authRef ? 'Yes (AuthRef)' : 'No' }}</dd> <dd>{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}</dd>
<dt>Created</dt> <dt>Created</dt>
<dd>{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}</dd> <dd>{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}</dd>
<dt>Updated</dt> <dt>Updated</dt>
<dd>{{ integration.modifiedAt ? (integration.modifiedAt | date:'medium') : 'Never' }} by {{ integration.modifiedBy || 'system' }}</dd> <dd>{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ integration.updatedBy || 'system' }}</dd>
</dl> </dl>
<h3>Tags</h3> <h3>Tags</h3>
@if (integration.tags) { @if (integration.tags.length > 0) {
<div class="tags"> <div class="tags">
@for (tag of getTagsArray(integration.tags); track tag) { @for (tag of integration.tags; track tag) {
<span class="tag">{{ tag }}</span> <span class="tag">{{ tag }}</span>
} }
</div> </div>
} }
@if (!integration.tags) { @if (integration.tags.length === 0) {
<p class="placeholder">No tags.</p> <p class="placeholder">No tags.</p>
} }
</div> </div>
@@ -106,12 +109,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
<div class="tab-panel"> <div class="tab-panel">
<h2>Credentials</h2> <h2>Credentials</h2>
<dl class="config-list"> <dl class="config-list">
<dt>Auth Reference</dt> <dt>Credential Mode</dt>
<dd>{{ integration.authRef || 'Not configured' }}</dd> <dd>{{ integration.hasAuth ? 'AuthRef-backed' : 'No credential reference configured' }}</dd>
<dt>Credential Status</dt> <dt>Credential Status</dt>
<dd>{{ integration.lastTestSuccess ? 'Valid on last check' : 'Requires attention' }}</dd> <dd>{{ integration.hasAuth ? 'Stored outside StellaOps and resolved on demand.' : 'Requires an AuthRef URI before tests can succeed.' }}</dd>
<dt>Last Validation</dt> <dt>Last Validation</dt>
<dd>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</dd> <dd>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}</dd>
<dt>Rotation</dt> <dt>Rotation</dt>
<dd>Managed by integration owner workflow.</dd> <dd>Managed by integration owner workflow.</dd>
</dl> </dl>
@@ -172,18 +175,18 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
<span [innerHTML]="lastTestResult.success ? successIconSvg : failureIconSvg"></span> <span [innerHTML]="lastTestResult.success ? successIconSvg : failureIconSvg"></span>
{{ lastTestResult.success ? 'Success' : 'Failed' }} {{ lastTestResult.success ? 'Success' : 'Failed' }}
</div> </div>
<p>{{ lastTestResult.errorMessage || 'Connection successful' }}</p> <p>{{ lastTestResult.message || 'Connection successful.' }}</p>
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} ({{ lastTestResult.latencyMs || 0 }}ms)</small> <small>Tested at {{ lastTestResult.testedAt | date:'medium' }} (duration {{ lastTestResult.duration }})</small>
</div> </div>
} }
@if (lastHealthResult) { @if (lastHealthResult) {
<div class="result-card"> <div class="result-card">
<h3>Last Health Check</h3> <h3>Last Health Check</h3>
<div [class]="'health-badge health-' + getStatusColor(lastHealthResult.status)"> <div [class]="'health-badge health-' + getHealthColor(lastHealthResult.status)">
{{ getStatusLabel(lastHealthResult.status) }} {{ getHealthLabel(lastHealthResult.status) }}
</div> </div>
<p>{{ lastHealthResult.lastTestSuccess ? 'Service is healthy' : 'Service has issues' }}</p> <p>{{ lastHealthResult.message || 'No health detail returned.' }}</p>
<small>Checked at {{ lastHealthResult.lastTestedAt | date:'medium' }} ({{ lastHealthResult.averageLatencyMs || 0 }}ms avg)</small> <small>Checked at {{ lastHealthResult.checkedAt | date:'medium' }} (duration {{ lastHealthResult.duration }})</small>
</div> </div>
} }
</div> </div>
@@ -392,7 +395,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
.status-active, .health-healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .status-active, .health-healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-pending, .health-unknown { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } .status-pending, .health-unknown { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.status-failed, .health-unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); } .status-failed, .health-unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.status-disabled, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); } .status-disabled, .status-archived, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); }
.placeholder { color: var(--color-text-secondary); font-style: italic; } .placeholder { color: var(--color-text-secondary); font-style: italic; }
.detail-state { .detail-state {
@@ -489,7 +492,7 @@ export class IntegrationDetailComponent implements OnInit {
testConnection(): void { testConnection(): void {
if (!this.integration) return; if (!this.integration) return;
this.testing = true; this.testing = true;
this.integrationService.testConnection(this.integration.integrationId).pipe( this.integrationService.testConnection(this.integration.id).pipe(
timeout({ first: this.requestTimeoutMs }), timeout({ first: this.requestTimeoutMs }),
).subscribe({ ).subscribe({
next: (result) => { next: (result) => {
@@ -497,7 +500,7 @@ export class IntegrationDetailComponent implements OnInit {
this.lastTestResult = result; this.lastTestResult = result;
this.testing = false; this.testing = false;
}); });
this.loadIntegration(this.integration!.integrationId); this.loadIntegration(this.integration!.id);
}, },
error: (err) => { error: (err) => {
console.error('Test connection failed:', err); console.error('Test connection failed:', err);
@@ -511,7 +514,7 @@ export class IntegrationDetailComponent implements OnInit {
checkHealth(): void { checkHealth(): void {
if (!this.integration) return; if (!this.integration) return;
this.checking = true; this.checking = true;
this.integrationService.getHealth(this.integration.integrationId).pipe( this.integrationService.getHealth(this.integration.id).pipe(
timeout({ first: this.requestTimeoutMs }), timeout({ first: this.requestTimeoutMs }),
).subscribe({ ).subscribe({
next: (result) => { next: (result) => {
@@ -519,7 +522,7 @@ export class IntegrationDetailComponent implements OnInit {
this.lastHealthResult = result; this.lastHealthResult = result;
this.checking = false; this.checking = false;
}); });
this.loadIntegration(this.integration!.integrationId); this.loadIntegration(this.integration!.id);
}, },
error: (err) => { error: (err) => {
console.error('Health check failed:', err); console.error('Health check failed:', err);
@@ -539,6 +542,14 @@ export class IntegrationDetailComponent implements OnInit {
return getIntegrationStatusColor(status); return getIntegrationStatusColor(status);
} }
getHealthLabel(status: HealthStatus): string {
return getHealthStatusLabel(status);
}
getHealthColor(status: HealthStatus): string {
return getHealthStatusColor(status);
}
getTypeLabel(type: number): string { getTypeLabel(type: number): string {
return getIntegrationTypeLabel(type); return getIntegrationTypeLabel(type);
} }
@@ -547,17 +558,13 @@ export class IntegrationDetailComponent implements OnInit {
return getProviderLabel(provider); return getProviderLabel(provider);
} }
getTagsArray(tags: string): string[] {
return tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [];
}
integrationHubRoute(): string[] { integrationHubRoute(): string[] {
return this.integrationCommands(); return this.integrationCommands();
} }
editIntegration(): void { editIntegration(): void {
if (!this.integration) return; if (!this.integration) return;
void this.router.navigate(this.integrationCommands(this.integration.integrationId), { void this.router.navigate(this.integrationCommands(this.integration.id), {
queryParams: { edit: '1' }, queryParams: { edit: '1' },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
}); });
@@ -566,7 +573,7 @@ export class IntegrationDetailComponent implements OnInit {
deleteIntegration(): void { deleteIntegration(): void {
if (!this.integration) return; if (!this.integration) return;
if (confirm('Are you sure you want to delete this integration?')) { if (confirm('Are you sure you want to delete this integration?')) {
this.integrationService.delete(this.integration.integrationId).subscribe({ this.integrationService.delete(this.integration.id).subscribe({
next: () => { next: () => {
void this.router.navigate(this.integrationCommands()); void this.router.navigate(this.integrationCommands());
}, },

View File

@@ -201,6 +201,9 @@ export class IntegrationHubComponent {
} }
addIntegration(): void { addIntegration(): void {
void this.router.navigate(['onboarding'], { relativeTo: this.route }); void this.router.navigate(['onboarding'], {
relativeTo: this.route,
queryParamsHandling: 'merge',
});
} }
} }

View File

@@ -1,143 +1,113 @@
import { Component, input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { of } from 'rxjs';
import { IntegrationListComponent } from './integration-list.component';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { Integration, IntegrationType, IntegrationStatus } from './integration.models'; import {
HealthStatus,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
} from './integration.models';
import { IntegrationListComponent } from './integration-list.component';
@Component({
selector: 'st-doctor-checks-inline',
standalone: true,
template: '',
})
class DoctorChecksInlineStubComponent {
readonly category = input<string>('');
readonly heading = input<string>('');
}
describe('IntegrationListComponent', () => { describe('IntegrationListComponent', () => {
let component: IntegrationListComponent;
let fixture: ComponentFixture<IntegrationListComponent>; let fixture: ComponentFixture<IntegrationListComponent>;
let httpMock: HttpTestingController; let component: IntegrationListComponent;
let integrationService: jasmine.SpyObj<IntegrationService>;
const mockIntegrations: Integration[] = [ let router: Router;
{
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' },
createdAt: '2025-12-29T12:00:00Z',
updatedAt: '2025-12-29T12:00:00Z',
createdBy: 'admin'
},
{
id: '2',
name: 'GitHub App',
type: IntegrationType.Scm,
provider: 'github-app',
status: IntegrationStatus.Error,
description: 'Source control integration',
tags: ['dev'],
configuration: { appId: '12345' },
createdAt: '2025-12-28T10:00:00Z',
updatedAt: '2025-12-29T10:00:00Z',
createdBy: 'admin',
lastError: 'Authentication failed'
}
];
beforeEach(async () => { beforeEach(async () => {
integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['list', 'testConnection', 'getHealth']);
integrationService.list.and.returnValue(of({
items: [
{
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'],
},
],
totalCount: 1,
page: 1,
pageSize: 20,
totalPages: 1,
}));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IntegrationListComponent], imports: [IntegrationListComponent],
providers: [ providers: [
IntegrationService, provideRouter([]),
provideHttpClient(), { provide: IntegrationService, useValue: integrationService },
provideHttpClientTesting() {
] provide: ActivatedRoute,
}).compileComponents(); useValue: {
snapshot: {
data: { type: 'Registry' },
},
},
},
],
})
.overrideComponent(IntegrationListComponent, {
remove: { imports: [DoctorChecksInlineComponent] },
add: { imports: [DoctorChecksInlineStubComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(IntegrationListComponent); fixture = TestBed.createComponent(IntegrationListComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController); router = TestBed.inject(Router);
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load integrations on init', () => {
fixture.detectChanges(); fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/integrations');
expect(req.request.method).toBe('GET');
req.flush(mockIntegrations);
expect(component.integrations.length).toBe(2);
}); });
it('should filter integrations by type', () => { it('loads canonical list responses and renders health from lastHealthStatus', () => {
fixture.detectChanges(); expect(component.integrations.length).toBe(1);
expect(component.integrations[0].id).toBe('int-1');
const req = httpMock.expectOne('/api/v1/integrations'); expect(component.getHealthLabel(component.integrations[0].lastHealthStatus)).toBe('Healthy');
req.flush(mockIntegrations);
component.filterByType(IntegrationType.Registry);
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].type).toBe(IntegrationType.Registry);
}); });
it('should filter integrations by status', () => { it('passes backend status filters through the list query', () => {
fixture.detectChanges(); component.filterStatus = IntegrationStatus.Disabled;
component.loadIntegrations();
const req = httpMock.expectOne('/api/v1/integrations'); expect(integrationService.list).toHaveBeenCalledWith(jasmine.objectContaining({
req.flush(mockIntegrations); type: IntegrationType.Registry,
status: IntegrationStatus.Disabled,
component.filterByStatus(IntegrationStatus.Error); }));
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].status).toBe(IntegrationStatus.Error);
}); });
it('should filter integrations by search text', () => { it('preserves the current scope query when opening typed onboarding', () => {
fixture.detectChanges(); const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
const req = httpMock.expectOne('/api/v1/integrations'); component.addIntegration();
req.flush(mockIntegrations);
component.searchText = 'Harbor'; expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'onboarding', 'registry'], {
component.applyFilters(); queryParamsHandling: 'merge',
});
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].name).toContain('Harbor');
});
it('should return correct status badge class', () => {
expect(component.getStatusBadgeClass(IntegrationStatus.Active)).toBe('badge-success');
expect(component.getStatusBadgeClass(IntegrationStatus.Error)).toBe('badge-danger');
expect(component.getStatusBadgeClass(IntegrationStatus.Disabled)).toBe('badge-secondary');
expect(component.getStatusBadgeClass(IntegrationStatus.Pending)).toBe('badge-warning');
});
it('should emit selection event when integration clicked', () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
const selectSpy = spyOn(component.integrationSelected, 'emit');
component.onSelect(mockIntegrations[0]);
expect(selectSpy).toHaveBeenCalledWith(mockIntegrations[0]);
});
it('should display error count for integrations with errors', () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
fixture.detectChanges();
const errorIntegration = component.integrations.find(i => i.status === IntegrationStatus.Error);
expect(errorIntegration?.lastError).toBe('Authentication failed');
}); });
}); });

View File

@@ -7,9 +7,12 @@ import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { integrationWorkspaceCommands } from './integration-route-context'; import { integrationWorkspaceCommands } from './integration-route-context';
import { import {
HealthStatus,
Integration, Integration,
IntegrationType, IntegrationType,
IntegrationStatus, IntegrationStatus,
getHealthStatusColor,
getHealthStatusLabel,
getIntegrationStatusLabel, getIntegrationStatusLabel,
getIntegrationStatusColor, getIntegrationStatusColor,
getProviderLabel, getProviderLabel,
@@ -32,11 +35,11 @@ import {
<section class="filters"> <section class="filters">
<select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select"> <select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select">
<option [ngValue]="undefined">All Statuses</option> <option [ngValue]="undefined">All Statuses</option>
<option [ngValue]="IntegrationStatus.Pending">Pending</option>
<option [ngValue]="IntegrationStatus.Active">Active</option> <option [ngValue]="IntegrationStatus.Active">Active</option>
<option [ngValue]="IntegrationStatus.PendingVerification">Pending</option>
<option [ngValue]="IntegrationStatus.Degraded">Degraded</option>
<option [ngValue]="IntegrationStatus.Paused">Paused</option>
<option [ngValue]="IntegrationStatus.Failed">Failed</option> <option [ngValue]="IntegrationStatus.Failed">Failed</option>
<option [ngValue]="IntegrationStatus.Disabled">Disabled</option>
<option [ngValue]="IntegrationStatus.Archived">Archived</option>
</select> </select>
<input <input
type="text" type="text"
@@ -77,10 +80,10 @@ import {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (integration of integrations; track integration.integrationId) { @for (integration of integrations; track integration.id) {
<tr> <tr>
<td> <td>
<a [routerLink]="integrationDetailRoute(integration.integrationId)">{{ integration.name }}</a> <a [routerLink]="integrationDetailRoute(integration.id)">{{ integration.name }}</a>
</td> </td>
<td>{{ getProviderName(integration.provider) }}</td> <td>{{ getProviderName(integration.provider) }}</td>
<td> <td>
@@ -89,16 +92,16 @@ import {
</span> </span>
</td> </td>
<td> <td>
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')"> <span [class]="'health-badge health-' + getHealthColor(integration.lastHealthStatus)">
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }} {{ getHealthLabel(integration.lastHealthStatus) }}
</span> </span>
</td> </td>
<td>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'short') : 'Never' }}</td> <td>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'short') : 'Never' }}</td>
<td class="actions"> <td class="actions">
<button (click)="editIntegration(integration)" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button> <button (click)="editIntegration(integration)" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
<button (click)="testConnection(integration)" title="Test Connection"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button> <button (click)="testConnection(integration)" title="Test Connection"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
<button (click)="checkHealth(integration)" title="Check Health"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button> <button (click)="checkHealth(integration)" title="Check Health"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
<a [routerLink]="integrationDetailRoute(integration.integrationId)" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a> <a [routerLink]="integrationDetailRoute(integration.id)" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
</td> </td>
</tr> </tr>
} }
@@ -194,7 +197,7 @@ import {
color: var(--color-status-error-text); color: var(--color-status-error-text);
} }
.status-disabled, .health-degraded { .status-disabled, .status-archived, .health-degraded {
background: var(--color-border-primary); background: var(--color-border-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@@ -355,9 +358,9 @@ export class IntegrationListComponent implements OnInit {
} }
testConnection(integration: Integration): void { testConnection(integration: Integration): void {
this.integrationService.testConnection(integration.integrationId).subscribe({ this.integrationService.testConnection(integration.id).subscribe({
next: (result) => { next: (result) => {
alert(result.success ? 'Connection successful!' : `Connection failed: ${result.errorMessage || 'Unknown error'}`); alert(result.success ? `Connection successful: ${result.message || 'Connector responded successfully.'}` : `Connection failed: ${result.message || 'Unknown error'}`);
this.loadIntegrations(); this.loadIntegrations();
}, },
error: (err) => { error: (err) => {
@@ -367,9 +370,9 @@ export class IntegrationListComponent implements OnInit {
} }
checkHealth(integration: Integration): void { checkHealth(integration: Integration): void {
this.integrationService.getHealth(integration.integrationId).subscribe({ this.integrationService.getHealth(integration.id).subscribe({
next: (result) => { next: (result) => {
alert(`Health: ${getIntegrationStatusLabel(result.status)} - ${result.lastTestSuccess ? 'OK' : 'Issues detected'}`); alert(`Health: ${getHealthStatusLabel(result.status)} - ${result.message || 'No detail returned.'}`);
this.loadIntegrations(); this.loadIntegrations();
}, },
error: (err) => { error: (err) => {
@@ -387,6 +390,14 @@ export class IntegrationListComponent implements OnInit {
return getIntegrationStatusColor(status); return getIntegrationStatusColor(status);
} }
getHealthLabel(status: HealthStatus): string {
return getHealthStatusLabel(status);
}
getHealthColor(status: HealthStatus): string {
return getHealthStatusColor(status);
}
getProviderName(provider: number): string { getProviderName(provider: number): string {
return getProviderLabel(provider); return getProviderLabel(provider);
} }
@@ -406,7 +417,7 @@ export class IntegrationListComponent implements OnInit {
} }
editIntegration(integration: Integration): void { editIntegration(integration: Integration): void {
void this.router.navigate(this.integrationCommands(integration.integrationId), { void this.router.navigate(this.integrationCommands(integration.id), {
queryParams: { edit: true }, queryParams: { edit: true },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
}); });
@@ -417,7 +428,9 @@ export class IntegrationListComponent implements OnInit {
? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType)) ? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
: this.integrationCommands('onboarding'); : this.integrationCommands('onboarding');
void this.router.navigate(commands); void this.router.navigate(commands, {
queryParamsHandling: 'merge',
});
} }
private parseType(typeStr: string): IntegrationType | undefined { private parseType(typeStr: string): IntegrationType | undefined {

View File

@@ -1,8 +1,3 @@
/**
* Integration Catalog Models
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
*/
export enum IntegrationType { export enum IntegrationType {
Registry = 1, Registry = 1,
Scm = 2, Scm = 2,
@@ -14,85 +9,85 @@ export enum IntegrationType {
Marketplace = 8, Marketplace = 8,
} }
export enum IntegrationStatus {
Draft = 0,
PendingVerification = 1,
Active = 2,
Degraded = 3,
Paused = 4,
Failed = 5,
}
export enum IntegrationProvider { export enum IntegrationProvider {
// Registry providers Harbor = 100,
DockerHub = 100, Ecr = 101,
Harbor = 101, Gcr = 102,
Ecr = 102,
Acr = 103, Acr = 103,
Gcr = 104, DockerHub = 104,
Ghcr = 105, Quay = 105,
Quay = 106, Artifactory = 106,
JfrogArtifactory = 107, Nexus = 107,
GitHubContainerRegistry = 108,
// SCM providers GitLabContainerRegistry = 109,
GitHub = 200, GitHubApp = 200,
GitLab = 201, GitLabServer = 201,
Gitea = 202, Bitbucket = 202,
Bitbucket = 203, Gitea = 203,
AzureDevOps = 204, AzureDevOps = 204,
// CI providers
GitHubActions = 300, GitHubActions = 300,
GitLabCi = 301, GitLabCi = 301,
GiteaActions = 302, Jenkins = 302,
Jenkins = 303, CircleCi = 303,
CircleCi = 304, AzurePipelines = 304,
AzurePipelines = 305, ArgoWorkflows = 305,
Tekton = 306,
NpmRegistry = 400,
PyPi = 401,
MavenCentral = 402,
NuGetOrg = 403,
CratesIo = 404,
GoProxy = 405,
EbpfAgent = 500,
EtwAgent = 501,
DyldInterposer = 502,
StellaOpsMirror = 600,
NvdMirror = 601,
OsvMirror = 602,
MicrosoftSymbols = 700,
UbuntuDebuginfod = 701,
FedoraDebuginfod = 702,
DebianDebuginfod = 703,
PartnerSymbols = 704,
CommunityFixes = 800,
PartnerFixes = 801,
VendorFixes = 802,
InMemory = 900,
Custom = 999,
}
// Host providers export enum IntegrationStatus {
ZastavaEbpf = 400, Pending = 0,
ZastavaEtw = 401, Active = 1,
ZastavaDyld = 402, Failed = 2,
Disabled = 3,
Archived = 4,
}
// Feed providers export enum HealthStatus {
Concelier = 500, Unknown = 0,
Excititor = 501, Healthy = 1,
Degraded = 2,
// Artifact providers Unhealthy = 3,
SbomUpload = 600,
VexUpload = 601,
} }
export interface Integration { export interface Integration {
integrationId: string; id: string;
tenantId: string;
name: string; name: string;
description?: string; description?: string | null;
type: IntegrationType; type: IntegrationType;
provider: IntegrationProvider; provider: IntegrationProvider;
status: IntegrationStatus; status: IntegrationStatus;
baseUrl?: string; endpoint: string;
authRef?: string; hasAuth: boolean;
configuration?: Record<string, unknown>; organizationId?: string | null;
environment?: string; lastHealthStatus: HealthStatus;
tags?: string; lastHealthCheckAt?: string | null;
ownerId?: string;
createdAt: string; createdAt: string;
createdBy: string; updatedAt: string;
modifiedAt?: string; createdBy?: string | null;
modifiedBy?: string; updatedBy?: string | null;
lastTestedAt?: string; tags: string[];
lastTestSuccess?: boolean;
lastTestError?: string;
lastSyncAt?: string;
lastEventAt?: string;
paused: boolean;
pauseReason?: string;
pauseTicket?: string;
pausedAt?: string;
pausedBy?: string;
consecutiveFailures: number;
version: number;
} }
export interface IntegrationListResponse { export interface IntegrationListResponse {
@@ -100,59 +95,56 @@ export interface IntegrationListResponse {
totalCount: number; totalCount: number;
page: number; page: number;
pageSize: number; pageSize: number;
hasMore: boolean; totalPages: number;
} }
export interface CreateIntegrationRequest { export interface CreateIntegrationRequest {
name: string; name: string;
description?: string; description?: string | null;
type: IntegrationType; type: IntegrationType;
provider: IntegrationProvider; provider: IntegrationProvider;
baseUrl?: string; endpoint: string;
authRef?: string; authRefUri?: string | null;
configuration?: Record<string, unknown>; organizationId?: string | null;
environment?: string; extendedConfig?: Record<string, unknown> | null;
tags?: string; tags?: string[] | null;
ownerId?: string;
} }
export interface UpdateIntegrationRequest { export interface UpdateIntegrationRequest {
name?: string; name?: string | null;
description?: string; description?: string | null;
baseUrl?: string; endpoint?: string | null;
authRef?: string; authRefUri?: string | null;
configuration?: Record<string, unknown>; organizationId?: string | null;
environment?: string; extendedConfig?: Record<string, unknown> | null;
tags?: string; tags?: string[] | null;
ownerId?: string; status?: IntegrationStatus | null;
}
export interface PauseIntegrationRequest {
reason: string;
ticket?: string;
} }
export interface TestConnectionResponse { export interface TestConnectionResponse {
integrationId: string;
success: boolean; success: boolean;
errorMessage?: string; message?: string | null;
details?: Record<string, string> | null;
duration: string;
testedAt: string; testedAt: string;
latencyMs?: number;
details?: Record<string, unknown>;
} }
export interface IntegrationHealthResponse { export interface IntegrationHealthResponse {
integrationId: string; integrationId: string;
status: IntegrationStatus; status: HealthStatus;
lastTestedAt?: string; message?: string | null;
lastTestSuccess?: boolean; details?: Record<string, string> | null;
lastSyncAt?: string; checkedAt: string;
lastEventAt?: string; duration: string;
consecutiveFailures: number; }
uptimePercentage?: number;
averageLatencyMs?: number; export interface SupportedProviderInfo {
name: string;
type: IntegrationType;
provider: IntegrationProvider;
} }
// Display helpers
export function getIntegrationTypeLabel(type: IntegrationType): string { export function getIntegrationTypeLabel(type: IntegrationType): string {
switch (type) { switch (type) {
case IntegrationType.Registry: case IntegrationType.Registry:
@@ -162,7 +154,7 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
case IntegrationType.CiCd: case IntegrationType.CiCd:
return 'CI/CD'; return 'CI/CD';
case IntegrationType.RepoSource: case IntegrationType.RepoSource:
return 'Repo Source'; return 'Repository Source';
case IntegrationType.RuntimeHost: case IntegrationType.RuntimeHost:
return 'Runtime Host'; return 'Runtime Host';
case IntegrationType.FeedMirror: case IntegrationType.FeedMirror:
@@ -178,18 +170,16 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
export function getIntegrationStatusLabel(status: IntegrationStatus): string { export function getIntegrationStatusLabel(status: IntegrationStatus): string {
switch (status) { switch (status) {
case IntegrationStatus.Draft: case IntegrationStatus.Pending:
return 'Draft';
case IntegrationStatus.PendingVerification:
return 'Pending'; return 'Pending';
case IntegrationStatus.Active: case IntegrationStatus.Active:
return 'Active'; return 'Active';
case IntegrationStatus.Degraded:
return 'Degraded';
case IntegrationStatus.Paused:
return 'Paused';
case IntegrationStatus.Failed: case IntegrationStatus.Failed:
return 'Failed'; return 'Failed';
case IntegrationStatus.Disabled:
return 'Disabled';
case IntegrationStatus.Archived:
return 'Archived';
default: default:
return 'Unknown'; return 'Unknown';
} }
@@ -197,76 +187,139 @@ export function getIntegrationStatusLabel(status: IntegrationStatus): string {
export function getIntegrationStatusColor(status: IntegrationStatus): string { export function getIntegrationStatusColor(status: IntegrationStatus): string {
switch (status) { switch (status) {
case IntegrationStatus.Pending:
return 'pending';
case IntegrationStatus.Active: case IntegrationStatus.Active:
return 'success'; return 'active';
case IntegrationStatus.Draft:
case IntegrationStatus.PendingVerification:
return 'info';
case IntegrationStatus.Degraded:
return 'warning';
case IntegrationStatus.Paused:
return 'secondary';
case IntegrationStatus.Failed: case IntegrationStatus.Failed:
return 'danger'; return 'failed';
case IntegrationStatus.Disabled:
return 'disabled';
case IntegrationStatus.Archived:
return 'archived';
default: default:
return 'secondary'; return 'unknown';
}
}
export function getHealthStatusLabel(status: HealthStatus): string {
switch (status) {
case HealthStatus.Healthy:
return 'Healthy';
case HealthStatus.Degraded:
return 'Degraded';
case HealthStatus.Unhealthy:
return 'Unhealthy';
case HealthStatus.Unknown:
default:
return 'Unknown';
}
}
export function getHealthStatusColor(status: HealthStatus): string {
switch (status) {
case HealthStatus.Healthy:
return 'healthy';
case HealthStatus.Degraded:
return 'degraded';
case HealthStatus.Unhealthy:
return 'unhealthy';
case HealthStatus.Unknown:
default:
return 'unknown';
} }
} }
export function getProviderLabel(provider: IntegrationProvider): string { export function getProviderLabel(provider: IntegrationProvider): string {
switch (provider) { switch (provider) {
case IntegrationProvider.DockerHub:
return 'Docker Hub';
case IntegrationProvider.Harbor: case IntegrationProvider.Harbor:
return 'Harbor'; return 'Harbor';
case IntegrationProvider.Ecr: case IntegrationProvider.Ecr:
return 'AWS ECR'; return 'AWS ECR';
case IntegrationProvider.Acr:
return 'Azure ACR';
case IntegrationProvider.Gcr: case IntegrationProvider.Gcr:
return 'Google GCR'; return 'Google GCR';
case IntegrationProvider.Ghcr: case IntegrationProvider.Acr:
return 'GitHub GHCR'; return 'Azure ACR';
case IntegrationProvider.DockerHub:
return 'Docker Hub';
case IntegrationProvider.Quay: case IntegrationProvider.Quay:
return 'Quay.io'; return 'Quay';
case IntegrationProvider.JfrogArtifactory: case IntegrationProvider.Artifactory:
return 'JFrog Artifactory'; return 'Artifactory';
case IntegrationProvider.GitHub: case IntegrationProvider.Nexus:
return 'GitHub'; return 'Nexus';
case IntegrationProvider.GitLab: case IntegrationProvider.GitHubContainerRegistry:
return 'GitLab'; return 'GitHub Container Registry';
case IntegrationProvider.Gitea: case IntegrationProvider.GitLabContainerRegistry:
return 'Gitea'; return 'GitLab Container Registry';
case IntegrationProvider.GitHubApp:
return 'GitHub App';
case IntegrationProvider.GitLabServer:
return 'GitLab Server';
case IntegrationProvider.Bitbucket: case IntegrationProvider.Bitbucket:
return 'Bitbucket'; return 'Bitbucket';
case IntegrationProvider.Gitea:
return 'Gitea';
case IntegrationProvider.AzureDevOps: case IntegrationProvider.AzureDevOps:
return 'Azure DevOps'; return 'Azure DevOps';
case IntegrationProvider.GitHubActions: case IntegrationProvider.GitHubActions:
return 'GitHub Actions'; return 'GitHub Actions';
case IntegrationProvider.GitLabCi: case IntegrationProvider.GitLabCi:
return 'GitLab CI'; return 'GitLab CI';
case IntegrationProvider.GiteaActions:
return 'Gitea Actions';
case IntegrationProvider.Jenkins: case IntegrationProvider.Jenkins:
return 'Jenkins'; return 'Jenkins';
case IntegrationProvider.CircleCi: case IntegrationProvider.CircleCi:
return 'CircleCI'; return 'CircleCI';
case IntegrationProvider.AzurePipelines: case IntegrationProvider.AzurePipelines:
return 'Azure Pipelines'; return 'Azure Pipelines';
case IntegrationProvider.ZastavaEbpf: case IntegrationProvider.ArgoWorkflows:
return 'Zastava (eBPF)'; return 'Argo Workflows';
case IntegrationProvider.ZastavaEtw: case IntegrationProvider.Tekton:
return 'Zastava (ETW)'; return 'Tekton';
case IntegrationProvider.ZastavaDyld: case IntegrationProvider.NpmRegistry:
return 'Zastava (dyld)'; return 'npm Registry';
case IntegrationProvider.Concelier: case IntegrationProvider.PyPi:
return 'Concelier'; return 'PyPI';
case IntegrationProvider.Excititor: case IntegrationProvider.MavenCentral:
return 'Excititor'; return 'Maven Central';
case IntegrationProvider.SbomUpload: case IntegrationProvider.NuGetOrg:
return 'SBOM Upload'; return 'NuGet.org';
case IntegrationProvider.VexUpload: case IntegrationProvider.CratesIo:
return 'VEX Upload'; return 'crates.io';
case IntegrationProvider.GoProxy:
return 'Go Proxy';
case IntegrationProvider.EbpfAgent:
return 'eBPF Agent';
case IntegrationProvider.EtwAgent:
return 'ETW Agent';
case IntegrationProvider.DyldInterposer:
return 'dyld Interposer';
case IntegrationProvider.StellaOpsMirror:
return 'StellaOps Mirror';
case IntegrationProvider.NvdMirror:
return 'NVD Mirror';
case IntegrationProvider.OsvMirror:
return 'OSV Mirror';
case IntegrationProvider.MicrosoftSymbols:
return 'Microsoft Symbols';
case IntegrationProvider.UbuntuDebuginfod:
return 'Ubuntu Debuginfod';
case IntegrationProvider.FedoraDebuginfod:
return 'Fedora Debuginfod';
case IntegrationProvider.DebianDebuginfod:
return 'Debian Debuginfod';
case IntegrationProvider.PartnerSymbols:
return 'Partner Symbols';
case IntegrationProvider.CommunityFixes:
return 'Community Fixes';
case IntegrationProvider.PartnerFixes:
return 'Partner Fixes';
case IntegrationProvider.VendorFixes:
return 'Vendor Fixes';
case IntegrationProvider.InMemory:
return 'In-Memory';
case IntegrationProvider.Custom:
return 'Custom';
default: default:
return 'Unknown'; return 'Unknown';
} }

View File

@@ -1,34 +1,26 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models'; import {
HealthStatus,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
} from './integration.models';
describe('IntegrationService', () => { describe('IntegrationService', () => {
let service: IntegrationService; let service: IntegrationService;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
const mockIntegration: Integration = {
id: '1',
name: 'Harbor Registry',
type: IntegrationType.Registry,
provider: 'harbor',
status: IntegrationStatus.Active,
description: 'Test',
tags: [],
configuration: {},
createdAt: '2025-12-29T12:00:00Z',
updatedAt: '2025-12-29T12:00:00Z',
createdBy: 'admin'
};
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
IntegrationService, IntegrationService,
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting() provideHttpClientTesting(),
] ],
}); });
service = TestBed.inject(IntegrationService); service = TestBed.inject(IntegrationService);
@@ -39,155 +31,84 @@ describe('IntegrationService', () => {
httpMock.verify(); httpMock.verify();
}); });
it('should be created', () => { it('lists integrations with canonical query parameters', () => {
expect(service).toBeTruthy(); service.list({
type: IntegrationType.Registry,
status: IntegrationStatus.Active,
provider: IntegrationProvider.Harbor,
search: 'harbor',
page: 2,
pageSize: 10,
}).subscribe();
const req = httpMock.expectOne((request) =>
request.url === '/api/v1/integrations'
&& request.params.get('type') === '1'
&& request.params.get('status') === '1'
&& request.params.get('provider') === '100'
&& request.params.get('search') === 'harbor'
&& request.params.get('page') === '2'
&& request.params.get('pageSize') === '10');
expect(req.request.method).toBe('GET');
req.flush({ items: [], totalCount: 0, page: 2, pageSize: 10, totalPages: 0 });
}); });
describe('getIntegrations', () => { it('creates integrations against the canonical API contract', () => {
it('should fetch all integrations', () => { const request = {
const mockIntegrations = [mockIntegration]; name: 'Production Harbor',
description: null,
type: IntegrationType.Registry,
provider: IntegrationProvider.Harbor,
endpoint: 'https://harbor.example.com',
authRefUri: 'authref://vault/harbor#robot',
organizationId: 'platform',
extendedConfig: { namespaces: ['platform'] },
tags: ['prod'],
} as const;
service.getIntegrations().subscribe(integrations => { service.create(request).subscribe((integration) => {
expect(integrations.length).toBe(1); expect(integration.id).toBe('int-1');
expect(integrations[0].name).toBe('Harbor Registry'); expect(integration.endpoint).toBe('https://harbor.example.com');
}); expect(integration.hasAuth).toBeTrue();
const req = httpMock.expectOne('/api/v1/integrations');
expect(req.request.method).toBe('GET');
req.flush(mockIntegrations);
}); });
it('should filter by type', () => { const req = httpMock.expectOne('/api/v1/integrations');
service.getIntegrations(IntegrationType.Registry).subscribe(); expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(request);
const req = httpMock.expectOne('/api/v1/integrations?type=ContainerRegistry'); req.flush({
expect(req.request.method).toBe('GET'); id: 'int-1',
req.flush([]); name: 'Production Harbor',
}); description: null,
type: IntegrationType.Registry,
it('should filter by status', () => { provider: IntegrationProvider.Harbor,
service.getIntegrations(undefined, IntegrationStatus.Active).subscribe(); status: IntegrationStatus.Pending,
endpoint: 'https://harbor.example.com',
const req = httpMock.expectOne('/api/v1/integrations?status=Active'); hasAuth: true,
expect(req.request.method).toBe('GET'); organizationId: 'platform',
req.flush([]); lastHealthStatus: HealthStatus.Unknown,
lastHealthCheckAt: null,
createdAt: '2026-03-14T10:00:00Z',
updatedAt: '2026-03-14T10:00:00Z',
createdBy: 'demo-user',
updatedBy: 'demo-user',
tags: ['prod'],
}); });
}); });
describe('getIntegration', () => { it('retrieves the supported provider catalog from the canonical endpoint', () => {
it('should fetch a single integration by id', () => { service.getSupportedProviders().subscribe((providers) => {
service.getIntegration('1').subscribe(integration => { expect(providers).toEqual([
expect(integration.id).toBe('1'); { name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
}); { name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
]);
const req = httpMock.expectOne('/api/v1/integrations/1');
expect(req.request.method).toBe('GET');
req.flush(mockIntegration);
}); });
});
describe('createIntegration', () => { const req = httpMock.expectOne('/api/v1/integrations/providers');
it('should create a new integration', () => { expect(req.request.method).toBe('GET');
const createRequest = { req.flush([
name: 'New Registry', { name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
type: IntegrationType.Registry, { name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
provider: 'harbor', ]);
configuration: { endpoint: 'https://new.example.com' }
};
service.createIntegration(createRequest).subscribe(integration => {
expect(integration.name).toBe('New Registry');
});
const req = httpMock.expectOne('/api/v1/integrations');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(createRequest);
req.flush({ ...mockIntegration, ...createRequest });
});
});
describe('updateIntegration', () => {
it('should update an existing integration', () => {
const updateRequest = { name: 'Updated Name' };
service.updateIntegration('1', updateRequest).subscribe(integration => {
expect(integration.name).toBe('Updated Name');
});
const req = httpMock.expectOne('/api/v1/integrations/1');
expect(req.request.method).toBe('PUT');
req.flush({ ...mockIntegration, name: 'Updated Name' });
});
});
describe('deleteIntegration', () => {
it('should delete an integration', () => {
service.deleteIntegration('1').subscribe(result => {
expect(result).toBeTrue();
});
const req = httpMock.expectOne('/api/v1/integrations/1');
expect(req.request.method).toBe('DELETE');
req.flush(null, { status: 204, statusText: 'No Content' });
});
});
describe('testConnection', () => {
it('should test connection and return result', () => {
const testResult: ConnectionTestResult = {
success: true,
message: 'Connected',
latencyMs: 50
};
service.testConnection('1').subscribe(result => {
expect(result.success).toBeTrue();
expect(result.latencyMs).toBe(50);
});
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
expect(req.request.method).toBe('POST');
req.flush(testResult);
});
});
describe('enableIntegration', () => {
it('should enable an integration', () => {
service.enableIntegration('1').subscribe(integration => {
expect(integration.status).toBe(IntegrationStatus.Active);
});
const req = httpMock.expectOne('/api/v1/integrations/1/enable');
expect(req.request.method).toBe('POST');
req.flush({ ...mockIntegration, status: IntegrationStatus.Active });
});
});
describe('disableIntegration', () => {
it('should disable an integration', () => {
service.disableIntegration('1').subscribe(integration => {
expect(integration.status).toBe(IntegrationStatus.Disabled);
});
const req = httpMock.expectOne('/api/v1/integrations/1/disable');
expect(req.request.method).toBe('POST');
req.flush({ ...mockIntegration, status: IntegrationStatus.Disabled });
});
});
describe('getActivityLogs', () => {
it('should fetch activity logs for an integration', () => {
const mockLogs = [
{ id: '1', timestamp: '2025-12-29T12:00:00Z', action: 'created' }
];
service.getActivityLogs('1').subscribe(logs => {
expect(logs.length).toBe(1);
});
const req = httpMock.expectOne('/api/v1/integrations/1/activity');
expect(req.request.method).toBe('GET');
req.flush(mockLogs);
});
}); });
}); });

View File

@@ -3,21 +3,18 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { import {
Integration,
IntegrationListResponse,
CreateIntegrationRequest, CreateIntegrationRequest,
UpdateIntegrationRequest, Integration,
PauseIntegrationRequest,
TestConnectionResponse,
IntegrationHealthResponse, IntegrationHealthResponse,
IntegrationType, IntegrationListResponse,
IntegrationProvider,
IntegrationStatus, IntegrationStatus,
IntegrationType,
SupportedProviderInfo,
TestConnectionResponse,
UpdateIntegrationRequest,
} from './integration.models'; } from './integration.models';
/**
* Service for interacting with the Integration Catalog API.
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
*/
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -25,13 +22,10 @@ export class IntegrationService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/v1/integrations`; private readonly baseUrl = `${environment.apiBaseUrl}/v1/integrations`;
/**
* List integrations with filtering and pagination.
*/
list(params: { list(params: {
type?: IntegrationType; type?: IntegrationType;
provider?: IntegrationProvider;
status?: IntegrationStatus; status?: IntegrationStatus;
environment?: string;
search?: string; search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@@ -41,12 +35,12 @@ export class IntegrationService {
if (params.type !== undefined) { if (params.type !== undefined) {
httpParams = httpParams.set('type', params.type.toString()); httpParams = httpParams.set('type', params.type.toString());
} }
if (params.provider !== undefined) {
httpParams = httpParams.set('provider', params.provider.toString());
}
if (params.status !== undefined) { if (params.status !== undefined) {
httpParams = httpParams.set('status', params.status.toString()); httpParams = httpParams.set('status', params.status.toString());
} }
if (params.environment) {
httpParams = httpParams.set('environment', params.environment);
}
if (params.search) { if (params.search) {
httpParams = httpParams.set('search', params.search); httpParams = httpParams.set('search', params.search);
} }
@@ -60,66 +54,31 @@ export class IntegrationService {
return this.http.get<IntegrationListResponse>(this.baseUrl, { params: httpParams }); return this.http.get<IntegrationListResponse>(this.baseUrl, { params: httpParams });
} }
/**
* Get an integration by ID.
*/
get(integrationId: string): Observable<Integration> { get(integrationId: string): Observable<Integration> {
return this.http.get<Integration>(`${this.baseUrl}/${integrationId}`); return this.http.get<Integration>(`${this.baseUrl}/${integrationId}`);
} }
/**
* Create a new integration.
*/
create(request: CreateIntegrationRequest): Observable<Integration> { create(request: CreateIntegrationRequest): Observable<Integration> {
return this.http.post<Integration>(this.baseUrl, request); return this.http.post<Integration>(this.baseUrl, request);
} }
/**
* Update an existing integration.
*/
update(integrationId: string, request: UpdateIntegrationRequest): Observable<Integration> { update(integrationId: string, request: UpdateIntegrationRequest): Observable<Integration> {
return this.http.put<Integration>(`${this.baseUrl}/${integrationId}`, request); return this.http.put<Integration>(`${this.baseUrl}/${integrationId}`, request);
} }
/**
* Delete an integration.
*/
delete(integrationId: string): Observable<void> { delete(integrationId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${integrationId}`); return this.http.delete<void>(`${this.baseUrl}/${integrationId}`);
} }
/**
* Test connection to an integration.
*/
testConnection(integrationId: string): Observable<TestConnectionResponse> { testConnection(integrationId: string): Observable<TestConnectionResponse> {
return this.http.post<TestConnectionResponse>(`${this.baseUrl}/${integrationId}/test`, {}); return this.http.post<TestConnectionResponse>(`${this.baseUrl}/${integrationId}/test`, {});
} }
/**
* Pause an integration.
*/
pause(integrationId: string, request: PauseIntegrationRequest): Observable<Integration> {
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/pause`, request);
}
/**
* Resume a paused integration.
*/
resume(integrationId: string): Observable<Integration> {
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/resume`, {});
}
/**
* Activate a draft or pending integration.
*/
activate(integrationId: string): Observable<Integration> {
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/activate`, {});
}
/**
* Get health status of an integration.
*/
getHealth(integrationId: string): Observable<IntegrationHealthResponse> { getHealth(integrationId: string): Observable<IntegrationHealthResponse> {
return this.http.get<IntegrationHealthResponse>(`${this.baseUrl}/${integrationId}/health`); return this.http.get<IntegrationHealthResponse>(`${this.baseUrl}/${integrationId}/health`);
} }
getSupportedProviders(): Observable<SupportedProviderInfo[]> {
return this.http.get<SupportedProviderInfo[]>(`${this.baseUrl}/providers`);
}
} }

View File

@@ -1,6 +1,4 @@
<!-- Integration Wizard (Sprint: SPRINT_20251229_014) -->
<div class="wizard-container"> <div class="wizard-container">
<!-- Stepper Header -->
<div class="wizard-stepper"> <div class="wizard-stepper">
@for (step of steps; track step; let i = $index) { @for (step of steps; track step; let i = $index) {
<button <button
@@ -17,90 +15,131 @@
} }
</div> </div>
<!-- Wizard Content --> @if (errorMessage()) {
<div class="check-item status-error">
<span class="check-status">XX</span>
<div class="check-info">
<span class="check-name">Create failed</span>
<span class="check-message">{{ errorMessage() }}</span>
</div>
</div>
}
<div class="wizard-content"> <div class="wizard-content">
<!-- Provider Step -->
@if (currentStep() === 'provider') { @if (currentStep() === 'provider') {
<div class="step-content"> <div class="step-content">
<h2>Select {{ getTypeLabel() }} Provider</h2> <h2>Select {{ getTypeLabel() }} Provider</h2>
<p class="step-description">Choose the {{ getTypeLabel().toLowerCase() }} provider you want to integrate.</p> <p class="step-description">Choose from the connector plugins that are actually installed in this environment.</p>
<div class="provider-grid"> @if (supportedProviders().length === 0) {
@for (provider of providers(); track provider.id) { <div class="check-item status-warning">
<button <span class="check-status">!!</span>
class="provider-card" <div class="check-info">
[class.selected]="draft().provider === provider.id" <span class="check-name">No supported providers</span>
(click)="selectProvider(provider.id)" <span class="check-message">This category has no installed connector plugins yet.</span>
> </div>
<span class="provider-icon">{{ provider.icon }}</span> </div>
<span class="provider-name">{{ provider.name }}</span> } @else {
<span class="provider-desc">{{ provider.description }}</span> <div class="provider-grid">
</button> @for (provider of supportedProviders(); track provider.provider) {
} <button
</div> class="provider-card"
[class.selected]="draft().provider === provider.provider"
type="button"
(click)="selectProvider(provider.provider)"
>
<span class="provider-icon">{{ provider.icon }}</span>
<span class="provider-name">{{ provider.name }}</span>
<span class="provider-desc">{{ provider.description }}</span>
</button>
}
</div>
}
</div> </div>
} }
<!-- Auth Step -->
@if (currentStep() === 'auth') { @if (currentStep() === 'auth') {
<div class="step-content"> <div class="step-content">
<h2>Configure Authentication</h2> <h2>Connection & Credentials</h2>
<p class="step-description">Set up authentication for {{ selectedProvider()?.name }}.</p> <p class="step-description">StellaOps stores only AuthRef URIs here. Keep the actual secret in your vault.</p>
<div class="auth-methods"> @if (selectedProvider(); as provider) {
@for (method of authMethods(); track method.id) { <div class="auth-method-card selected">
<div <div class="form-field">
class="auth-method-card" <label for="endpoint">
[class.selected]="draft().authMethod === method.id" Endpoint
(click)="selectAuthMethod(method.id)" <span class="required">*</span>
> </label>
<div class="auth-method-header"> <input
<input id="endpoint"
type="radio" type="text"
[checked]="draft().authMethod === method.id" [value]="draft().endpoint"
[id]="'auth-' + method.id" [placeholder]="provider.defaultEndpoint"
/> (input)="updateEndpoint($any($event.target).value)"
<label [for]="'auth-' + method.id">{{ method.name }}</label> />
</div> <span class="field-hint">{{ provider.endpointHint }}</span>
<p class="auth-method-desc">{{ method.description }}</p>
@if (draft().authMethod === method.id) {
<div class="auth-fields">
@for (field of method.fields; track field.id) {
<div class="form-field">
<label [for]="'field-' + field.id">
{{ field.name }}
@if (field.required) {
<span class="required">*</span>
}
</label>
@if (field.type === 'text' || field.type === 'password') {
<input
[type]="field.type"
[id]="'field-' + field.id"
[placeholder]="field.placeholder || ''"
[value]="draft().authValues[field.id] || ''"
(input)="updateAuthValue(field.id, $any($event.target).value)"
/>
}
@if (field.hint) {
<span class="field-hint">{{ field.hint }}</span>
}
</div>
}
</div>
}
</div> </div>
}
</div> <div class="form-field">
<label for="authRefUri">
AuthRef URI
<span class="required">*</span>
</label>
<input
id="authRefUri"
type="text"
[value]="draft().authRefUri"
placeholder="authref://vault/path#secret"
(input)="updateAuthRefUri($any($event.target).value)"
/>
<span class="field-hint">{{ provider.authRefHint }}</span>
</div>
@if (provider.organizationLabel) {
<div class="form-field">
<label for="organizationId">{{ provider.organizationLabel }}</label>
<input
id="organizationId"
type="text"
[value]="draft().organizationId"
placeholder="team-platform"
(input)="updateOrganizationId($any($event.target).value)"
/>
@if (provider.organizationHint) {
<span class="field-hint">{{ provider.organizationHint }}</span>
}
</div>
}
@for (field of provider.configFields; track field.id) {
<div class="form-field">
<label [for]="'config-' + field.id">
{{ field.label }}
@if (field.required) {
<span class="required">*</span>
}
</label>
<input
[id]="'config-' + field.id"
type="text"
[value]="draft().extendedConfig[field.id] || ''"
[placeholder]="field.placeholder || ''"
(input)="updateConfigField(field.id, $any($event.target).value)"
/>
@if (field.hint) {
<span class="field-hint">{{ field.hint }}</span>
}
</div>
}
</div>
}
</div> </div>
} }
<!-- Scope Step -->
@if (currentStep() === 'scope') { @if (currentStep() === 'scope') {
<div class="step-content"> <div class="step-content">
<h2>Define Scope</h2> <h2>Discovery Scope</h2>
<p class="step-description">Specify which resources to include in this integration.</p> <p class="step-description">Define which repositories, namespaces, or tag patterns StellaOps should use for this connector.</p>
<div class="scope-form"> <div class="scope-form">
@if (integrationType() === 'registry' || integrationType() === 'scm') { @if (integrationType() === 'registry' || integrationType() === 'scm') {
@@ -108,11 +147,10 @@
<label for="repositories">Repositories</label> <label for="repositories">Repositories</label>
<textarea <textarea
id="repositories" id="repositories"
placeholder="One repository per line&#10;e.g., owner/repo"
rows="4" rows="4"
(input)="parseScopeInput('repositories', $any($event.target).value)" placeholder="One repository per line&#10;example/api&#10;platform/web"
>{{ (draft().scope.repositories || []).join('\n') }}</textarea> (input)="parseListInput('repositories', $any($event.target).value)"
<span class="field-hint">Leave empty to include all accessible repositories.</span> >{{ draft().repositories.join('\n') }}</textarea>
</div> </div>
} }
@@ -121,45 +159,44 @@
<label for="branches">Branch Patterns</label> <label for="branches">Branch Patterns</label>
<textarea <textarea
id="branches" id="branches"
placeholder="Branch patterns (glob supported)&#10;e.g., main, release/*"
rows="3" rows="3"
(input)="parseScopeInput('branches', $any($event.target).value)" placeholder="main&#10;release/*"
>{{ (draft().scope.branches || []).join('\n') }}</textarea> (input)="parseListInput('branches', $any($event.target).value)"
>{{ draft().branches.join('\n') }}</textarea>
</div> </div>
} }
@if (integrationType() === 'registry') { @if (integrationType() === 'registry') {
<div class="form-field">
<label for="namespaces">Namespaces / Projects</label>
<textarea
id="namespaces"
rows="3"
placeholder="platform&#10;customer-facing"
(input)="parseListInput('namespaces', $any($event.target).value)"
>{{ draft().namespaces.join('\n') }}</textarea>
</div>
<div class="form-field"> <div class="form-field">
<label for="tagPatterns">Tag Patterns</label> <label for="tagPatterns">Tag Patterns</label>
<textarea <textarea
id="tagPatterns" id="tagPatterns"
placeholder="Tag patterns (glob supported)&#10;e.g., v*, latest, release-*"
rows="3" rows="3"
(input)="parseScopeInput('tagPatterns', $any($event.target).value)" placeholder="latest&#10;release-*"
>{{ (draft().scope.tagPatterns || []).join('\n') }}</textarea> (input)="parseListInput('tagPatterns', $any($event.target).value)"
>{{ draft().tagPatterns.join('\n') }}</textarea>
</div> </div>
} }
@if (integrationType() === 'host') { <span class="field-hint">At least one owner, repository, namespace, branch, or tag scope is required before creation.</span>
<div class="form-field">
<label for="namespaces">Namespaces</label>
<textarea
id="namespaces"
placeholder="Kubernetes namespaces (leave empty for all)&#10;e.g., default, production"
rows="3"
(input)="parseScopeInput('namespaces', $any($event.target).value)"
>{{ (draft().scope.namespaces || []).join('\n') }}</textarea>
</div>
}
</div> </div>
</div> </div>
} }
<!-- Schedule Step -->
@if (currentStep() === 'schedule') { @if (currentStep() === 'schedule') {
<div class="step-content"> <div class="step-content">
<h2>Configure Schedule</h2> <h2>Check Schedule</h2>
<p class="step-description">Set up how often scans should run.</p> <p class="step-description">Define how StellaOps should revisit this connector after onboarding.</p>
<div class="schedule-options"> <div class="schedule-options">
@for (option of scheduleOptions; track option.value) { @for (option of scheduleOptions; track option.value) {
@@ -168,12 +205,8 @@
[class.selected]="draft().schedule.type === option.value" [class.selected]="draft().schedule.type === option.value"
(click)="updateSchedule('type', $any(option.value))" (click)="updateSchedule('type', $any(option.value))"
> >
<input <input type="radio" [checked]="draft().schedule.type === option.value" />
type="radio" <label>
[checked]="draft().schedule.type === option.value"
[id]="'schedule-' + option.value"
/>
<label [for]="'schedule-' + option.value">
<span class="schedule-label">{{ option.label }}</span> <span class="schedule-label">{{ option.label }}</span>
<span class="schedule-desc">{{ option.description }}</span> <span class="schedule-desc">{{ option.description }}</span>
</label> </label>
@@ -183,14 +216,14 @@
@if (draft().schedule.type === 'interval') { @if (draft().schedule.type === 'interval') {
<div class="form-field"> <div class="form-field">
<label for="interval">Scan Interval</label> <label for="interval">Check Interval</label>
<select <select
id="interval" id="interval"
[value]="draft().schedule.intervalMinutes || 60" [value]="draft().schedule.intervalMinutes || 60"
(change)="updateSchedule('intervalMinutes', +$any($event.target).value)" (change)="updateSchedule('intervalMinutes', +$any($event.target).value)"
> >
@for (opt of intervalOptions; track opt.value) { @for (option of intervalOptions; track option.value) {
<option [value]="opt.value">{{ opt.label }}</option> <option [value]="option.value">{{ option.label }}</option>
} }
</select> </select>
</div> </div>
@@ -198,55 +231,32 @@
@if (draft().schedule.type === 'cron') { @if (draft().schedule.type === 'cron') {
<div class="form-field"> <div class="form-field">
<label for="cron">Cron Expression</label> <label for="cronExpression">Cron Expression</label>
<input <input
id="cronExpression"
type="text" type="text"
id="cron"
placeholder="0 0 * * *"
[value]="draft().schedule.cronExpression || ''" [value]="draft().schedule.cronExpression || ''"
placeholder="0 */6 * * *"
(input)="updateSchedule('cronExpression', $any($event.target).value)" (input)="updateSchedule('cronExpression', $any($event.target).value)"
/> />
<span class="field-hint">Standard cron syntax (minute hour day month weekday)</span>
</div> </div>
} }
@if (integrationType() === 'registry' || integrationType() === 'scm') { @if (integrationType() === 'registry' || integrationType() === 'scm') {
<div class="webhook-toggle"> <div class="webhook-toggle">
<label class="toggle-label"> <label class="toggle-label">
<input <input type="checkbox" [checked]="draft().webhookEnabled" (change)="toggleWebhook()" />
type="checkbox" <span>Record webhook intent in connector metadata</span>
[checked]="draft().webhookEnabled"
(change)="toggleWebhook()"
/>
<span>Enable webhook for real-time triggers</span>
</label> </label>
@if (draft().webhookEnabled && draft().webhookSecret) {
<div class="webhook-secret">
<label>Webhook Secret</label>
<div class="secret-display">
<code>{{ draft().webhookSecret }}</code>
<button
type="button"
class="copy-btn"
(click)="copyToClipboard(draft().webhookSecret!)"
>
Copy
</button>
</div>
<span class="field-hint">Use this secret to configure the webhook in {{ selectedProvider()?.name }}.</span>
</div>
}
</div> </div>
} }
</div> </div>
} }
<!-- Preflight Step -->
@if (currentStep() === 'preflight') { @if (currentStep() === 'preflight') {
<div class="step-content"> <div class="step-content">
<h2>Preflight Checks</h2> <h2>Preflight Checks</h2>
<p class="step-description">Verifying your integration configuration.</p> <p class="step-description">Validate the draft against the connector contract before creation.</p>
<div class="preflight-checks"> <div class="preflight-checks">
@for (check of preflightChecks(); track check.id) { @for (check of preflightChecks(); track check.id) {
@@ -272,87 +282,48 @@
</div> </div>
@if (!preflightRunning() && preflightChecks().length > 0) { @if (!preflightRunning() && preflightChecks().length > 0) {
<button <button type="button" class="btn btn-secondary" (click)="runPreflightChecks()">Re-run Checks</button>
type="button"
class="btn btn-secondary"
(click)="runPreflightChecks()"
>
Re-run Checks
</button>
}
@if (preflightRunning()) {
<p class="running-message">Running preflight checks...</p>
} }
</div> </div>
} }
<!-- Review Step -->
@if (currentStep() === 'review') { @if (currentStep() === 'review') {
<div class="step-content"> <div class="step-content">
<h2>Review & Create</h2> <h2>Review & Create</h2>
<p class="step-description">Review your integration configuration before creating.</p> <p class="step-description">Review the canonical connector request before StellaOps persists it.</p>
<div class="form-field"> <div class="form-field">
<label for="name">Integration Name</label> <label for="integrationName">
Integration Name
<span class="required">*</span>
</label>
<input <input
id="integrationName"
type="text" type="text"
id="name"
[value]="draft().name" [value]="draft().name"
(input)="updateName($any($event.target).value)" (input)="updateName($any($event.target).value)"
placeholder="Enter a name for this integration" placeholder="Production Harbor Registry"
/> />
</div> </div>
<div class="review-summary"> @if (selectedProvider(); as provider) {
<div class="summary-section"> <div class="review-summary">
<h3>Provider</h3>
<p>{{ selectedProvider()?.name }} ({{ selectedProvider()?.type }})</p>
</div>
<div class="summary-section">
<h3>Authentication</h3>
<p>{{ selectedAuthMethod()?.name }}</p>
</div>
<div class="summary-section">
<h3>Schedule</h3>
<p>{{ draft().schedule.type | titlecase }}
@if (draft().schedule.type === 'interval') {
- Every {{ draft().schedule.intervalMinutes }} minutes
}
@if (draft().schedule.type === 'cron') {
- {{ draft().schedule.cronExpression }}
}
</p>
</div>
@if (draft().webhookEnabled) {
<div class="summary-section"> <div class="summary-section">
<h3>Webhook</h3> <h3>Provider</h3>
<p>Enabled</p> <p>{{ provider.name }}</p>
</div>
<div class="summary-section">
<h3>Endpoint</h3>
<p>{{ draft().endpoint }}</p>
</div>
<div class="summary-section">
<h3>AuthRef URI</h3>
<p>{{ draft().authRefUri }}</p>
</div>
<div class="summary-section">
<h3>Schedule</h3>
<p>{{ draft().schedule.type | titlecase }}</p>
</div> </div>
}
</div>
@if (deploymentTemplate()) {
<div class="deployment-template">
<h3>Deployment Template</h3>
<p class="template-hint">Copy-safe installer template with placeholder secret values.</p>
<pre><code>{{ deploymentTemplate() }}</code></pre>
<button
type="button"
class="btn btn-secondary"
(click)="copyToClipboard(deploymentTemplate()!)"
>
Copy Template
</button>
<ul class="copy-safety-list">
@for (item of copySafetyGuidance(); track item) {
<li>{{ item }}</li>
}
</ul>
</div> </div>
} }
@@ -381,7 +352,6 @@
} }
</div> </div>
<!-- Wizard Footer -->
<div class="wizard-footer"> <div class="wizard-footer">
<button type="button" class="btn btn-text" (click)="onCancel()">Cancel</button> <button type="button" class="btn btn-text" (click)="onCancel()">Cancel</button>
@@ -391,22 +361,10 @@
} }
@if (currentStep() !== 'review') { @if (currentStep() !== 'review') {
<button <button type="button" class="btn btn-primary" [disabled]="!canGoNext()" (click)="goNext()">Next</button>
type="button"
class="btn btn-primary"
[disabled]="!canGoNext()"
(click)="goNext()"
>
Next
</button>
} @else { } @else {
<button <button type="button" class="btn btn-primary" [disabled]="!canGoNext() || creating()" (click)="onSubmit()">
type="button" {{ creating() ? 'Creating...' : 'Create Integration' }}
class="btn btn-primary"
[disabled]="!canGoNext()"
(click)="onSubmit()"
>
Create Integration
</button> </button>
} }
</div> </div>

View File

@@ -1,264 +1,76 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { IntegrationProvider, IntegrationType } from '../integration-hub/integration.models';
import { By } from '@angular/platform-browser'; import { IntegrationWizardComponent } from './integration-wizard.component';
import { signal } from '@angular/core'; import { resolveSupportedProviders } from './models/integration.models';
import { IntegrationWizardComponent } from './integration-wizard.component';
import {
IntegrationType,
IntegrationProvider,
WizardStep,
REGISTRY_PROVIDERS,
SCM_PROVIDERS,
CI_PROVIDERS,
HOST_PROVIDERS,
} from './models/integration.models';
/**
* Unit tests for Integration Wizard Component
* @sprint SPRINT_20251229_014_FE_integration_wizards
*/
describe('IntegrationWizardComponent', () => { describe('IntegrationWizardComponent', () => {
let component: IntegrationWizardComponent; let component: IntegrationWizardComponent;
let fixture: ComponentFixture<IntegrationWizardComponent>;
beforeEach(async () => { function createComponent() {
await TestBed.configureTestingModule({ component = TestBed.runInInjectionContext(() => new IntegrationWizardComponent());
imports: [IntegrationWizardComponent, CommonModule, FormsModule], (component as any).integrationType = () => 'scm';
}).compileComponents(); (component as any).supportedProviders = () => resolveSupportedProviders('scm', [
}); { name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
]);
function createComponent(integrationType: IntegrationType = 'registry'): void { component.draft.update((draft) => ({ ...draft, type: 'scm' }));
fixture = TestBed.createComponent(IntegrationWizardComponent);
component = fixture.componentInstance;
// Use ComponentRef to set required inputs
fixture.componentRef.setInput('integrationType', integrationType);
fixture.detectChanges();
} }
describe('Initialization', () => { it('selects a supported provider and seeds its default endpoint', () => {
it('should create the component', () => { createComponent();
createComponent(); component.selectProvider(IntegrationProvider.GitHubApp);
expect(component).toBeTruthy();
});
it('should initialize with provider step', () => { expect(component.draft().provider).toBe(IntegrationProvider.GitHubApp);
createComponent(); expect(component.draft().endpoint).toBe('https://github.com');
expect(component.currentStep()).toBe('provider'); expect(component.draft().name).toContain('GitHub App');
});
it('should have 6 wizard steps', () => {
createComponent();
expect(component.steps).toEqual(['provider', 'auth', 'scope', 'schedule', 'preflight', 'review']);
});
it('should initialize with empty draft', () => {
createComponent();
const draft = component.draft();
expect(draft.name).toBe('');
expect(draft.provider).toBeNull();
expect(draft.authMethod).toBeNull();
});
}); });
describe('Provider Selection by Integration Type', () => { it('requires AuthRef URI and provider metadata before leaving the connection step', () => {
it('should show registry providers for registry type', () => { createComponent();
createComponent('registry'); component.selectProvider(IntegrationProvider.GitHubApp);
expect(component.providers()).toEqual(REGISTRY_PROVIDERS); component.currentStep.set('auth');
});
it('should show SCM providers for scm type', () => { expect(component.canGoNext()).toBeFalse();
createComponent('scm');
expect(component.providers()).toEqual(SCM_PROVIDERS);
});
it('should show CI providers for ci type', () => { component.updateAuthRefUri('authref://vault/github#app');
createComponent('ci'); component.updateConfigField('appId', '12345');
expect(component.providers()).toEqual(CI_PROVIDERS); component.updateConfigField('installationId', '67890');
});
it('should show host providers for host type', () => { expect(component.canGoNext()).toBeTrue();
createComponent('host');
expect(component.providers()).toEqual(HOST_PROVIDERS);
});
}); });
describe('Step Navigation', () => { it('emits a canonical create request instead of a UI-only draft', () => {
it('should compute current step index correctly', () => { createComponent();
createComponent(); component.selectProvider(IntegrationProvider.GitHubApp);
expect(component.currentStepIndex()).toBe(0); const emitSpy = jasmine.createSpy('emit');
}); spyOn(component.create, 'emit').and.callFake(emitSpy);
it('should prevent navigation when canGoNext is false', () => { component.updateAuthRefUri('authref://vault/github#app');
createComponent(); component.updateConfigField('appId', '12345');
// Without a provider selected, canGoNext should be false component.updateConfigField('installationId', '67890');
expect(component.canGoNext()).toBe(false); component.updateOrganizationId('platform');
}); component.parseListInput('repositories', 'platform/api');
component.updateSchedule('type', 'interval');
component.updateSchedule('intervalMinutes', 60);
component.updateName('GitHub App Platform');
component.currentStep.set('review');
it('should allow navigation after provider selection', () => { component.onSubmit();
createComponent();
const draft = component.draft();
component.draft.set({
...draft,
provider: 'docker-hub',
type: 'registry',
});
// After setting provider, should enable next
expect(component.draft().provider).toBe('docker-hub');
});
});
describe('Provider Selection', () => { expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
it('should update draft when provider is selected', () => { name: 'GitHub App Platform',
createComponent('registry'); type: IntegrationType.Scm,
const provider = REGISTRY_PROVIDERS[0]; provider: IntegrationProvider.GitHubApp,
component.selectProvider(provider.id); endpoint: 'https://github.com',
expect(component.draft().provider).toBe(provider.id); authRefUri: 'authref://vault/github#app',
}); organizationId: 'platform',
extendedConfig: jasmine.objectContaining({
it('should return selected provider info', () => { appId: '12345',
createComponent('registry'); installationId: '67890',
const provider = REGISTRY_PROVIDERS[0]; repositories: ['platform/api'],
component.selectProvider(provider.id); scheduleType: 'interval',
expect(component.selectedProvider()?.id).toBe(provider.id); intervalMinutes: 60,
}); }),
}); }));
describe('Auth Method Selection', () => {
it('should have auth methods for registry type', () => {
createComponent('registry');
const authMethods = component.authMethods();
expect(authMethods.length).toBeGreaterThan(0);
});
it('should update draft when auth method is selected', () => {
createComponent('registry');
const authMethods = component.authMethods();
if (authMethods.length > 0) {
component.selectAuthMethod(authMethods[0].id);
expect(component.draft().authMethod).toBe(authMethods[0].id);
}
});
});
describe('Draft Management', () => {
it('should update draft name', () => {
createComponent();
component.updateName('My Integration');
expect(component.draft().name).toBe('My Integration');
});
it('should add tags to draft', () => {
createComponent();
component.newTag.set('production');
component.addTag();
expect(component.draft().tags).toContain('production');
});
it('should not add duplicate tags', () => {
createComponent();
component.newTag.set('production');
component.addTag();
component.newTag.set('production');
component.addTag();
expect(component.draft().tags.filter(t => t === 'production').length).toBe(1);
});
it('should remove tags from draft', () => {
createComponent();
component.newTag.set('production');
component.addTag();
expect(component.draft().tags).toContain('production');
component.removeTag('production');
expect(component.draft().tags).not.toContain('production');
});
});
describe('Schedule Configuration', () => {
it('should default to manual schedule', () => {
createComponent();
expect(component.draft().schedule.type).toBe('manual');
});
it('should allow setting cron schedule type', () => {
createComponent();
component.updateSchedule('type', 'cron');
expect(component.draft().schedule.type).toBe('cron');
});
it('should allow setting cron expression', () => {
createComponent();
component.updateSchedule('cronExpression', '0 0 * * *');
expect(component.draft().schedule.cronExpression).toBe('0 0 * * *');
});
it('should allow setting interval schedule', () => {
createComponent();
component.updateSchedule('type', 'interval');
component.updateSchedule('intervalMinutes', 60);
expect(component.draft().schedule.type).toBe('interval');
expect(component.draft().schedule.intervalMinutes).toBe(60);
});
});
describe('Webhook Configuration', () => {
it('should default to webhook disabled', () => {
createComponent();
expect(component.draft().webhookEnabled).toBe(false);
});
it('should toggle webhook setting', () => {
createComponent();
component.toggleWebhook();
expect(component.draft().webhookEnabled).toBe(true);
component.toggleWebhook();
expect(component.draft().webhookEnabled).toBe(false);
});
});
describe('Preflight Checks', () => {
it('should initialize with empty preflight checks', () => {
createComponent();
expect(component.preflightChecks()).toEqual([]);
});
it('should track preflight running state', () => {
createComponent();
expect(component.preflightRunning()).toBe(false);
});
});
describe('Cancel and Create Outputs', () => {
it('should emit cancel event', () => {
createComponent();
const cancelSpy = jasmine.createSpy('cancel');
component.cancel.subscribe(cancelSpy);
component.onCancel();
expect(cancelSpy).toHaveBeenCalled();
});
it('should emit create event with draft via onSubmit', () => {
createComponent();
const createSpy = jasmine.createSpy('create');
component.create.subscribe(createSpy);
// Setup a complete draft
component.draft.set({
name: 'Test Integration',
provider: 'docker-hub',
type: 'registry',
authMethod: 'token',
authValues: { token: 'test-token' },
scope: { repositories: ['repo1'] },
schedule: { type: 'manual' },
webhookEnabled: false,
tags: ['test'],
});
// Navigate to review step so canGoNext is true
component.currentStep.set('review');
component.onSubmit();
expect(createSpy).toHaveBeenCalledWith(component.draft());
});
}); });
}); });

View File

@@ -3,30 +3,26 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
effect,
input, input,
output, output,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CreateIntegrationRequest } from '../integration-hub/integration.models';
import { import {
buildCreateIntegrationRequest,
IntegrationDraft, IntegrationDraft,
IntegrationProvider, IntegrationOnboardingType,
IntegrationProviderInfo, IntegrationProviderDefinition,
IntegrationType, intervalOptions,
WizardStep,
PreflightCheck, PreflightCheck,
AuthMethod, resolveProviderDefinition,
REGISTRY_PROVIDERS, scheduleOptions,
SCM_PROVIDERS, WizardStep,
CI_PROVIDERS,
HOST_PROVIDERS,
AUTH_METHODS,
} from './models/integration.models'; } from './models/integration.models';
/**
* Integration onboarding wizard component (Sprint: SPRINT_20251229_014)
* Provides guided setup for registry, SCM, CI, and host integrations.
*/
@Component({ @Component({
selector: 'app-integration-wizard', selector: 'app-integration-wizard',
standalone: true, standalone: true,
@@ -36,74 +32,55 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class IntegrationWizardComponent { export class IntegrationWizardComponent {
/** Integration type to create */ readonly integrationType = input.required<IntegrationOnboardingType>();
readonly integrationType = input.required<IntegrationType>(); readonly supportedProviders = input<readonly IntegrationProviderDefinition[]>([]);
readonly creating = input(false);
readonly errorMessage = input<string | null>(null);
/** Pre-selected provider */
readonly preselectedProvider = input<IntegrationProvider>();
/** Emits when wizard is cancelled */
readonly cancel = output<void>(); readonly cancel = output<void>();
readonly create = output<CreateIntegrationRequest>();
/** Emits when integration is created */
readonly create = output<IntegrationDraft>();
readonly steps: WizardStep[] = ['provider', 'auth', 'scope', 'schedule', 'preflight', 'review']; readonly steps: WizardStep[] = ['provider', 'auth', 'scope', 'schedule', 'preflight', 'review'];
readonly currentStep = signal<WizardStep>('provider'); readonly currentStep = signal<WizardStep>('provider');
readonly preflightChecks = signal<PreflightCheck[]>([]);
readonly preflightRunning = signal(false);
readonly newTag = signal('');
readonly draft = signal<IntegrationDraft>({ readonly draft = signal<IntegrationDraft>({
name: '', name: '',
provider: null, provider: null,
type: null, type: null,
authMethod: null, endpoint: '',
authValues: {}, authRefUri: '',
scope: {}, organizationId: '',
repositories: [],
branches: [],
namespaces: [],
tagPatterns: [],
schedule: { type: 'manual' }, schedule: { type: 'manual' },
webhookEnabled: false, webhookEnabled: false,
tags: [], tags: [],
extendedConfig: {},
}); });
readonly preflightChecks = signal<PreflightCheck[]>([]);
readonly preflightRunning = signal(false);
readonly newTag = signal('');
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep())); readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
readonly selectedProvider = computed(() => resolveProviderDefinition(this.draft().provider));
readonly deploymentTemplate = computed(() => null);
readonly copySafetyGuidance = computed(() => [
'Only AuthRef URIs are stored. Keep the underlying secret in a vault.',
'Non-secret provider fields such as App ID or Installation ID are stored as connector metadata.',
'Use environment-specific endpoints so prod, stage, and lab connectors remain explicit.',
]);
readonly scheduleOptions = scheduleOptions;
readonly intervalOptions = intervalOptions;
readonly providers = computed((): IntegrationProviderInfo[] => { readonly canGoBack = computed(() => this.currentStepIndex() > 0);
switch (this.integrationType()) {
case 'registry': return REGISTRY_PROVIDERS;
case 'scm': return SCM_PROVIDERS;
case 'ci': return CI_PROVIDERS;
case 'host': return HOST_PROVIDERS;
default: return [];
}
});
readonly authMethods = computed((): AuthMethod[] => {
return AUTH_METHODS[this.integrationType()] || [];
});
readonly selectedProvider = computed(() => {
const providerId = this.draft().provider;
return this.providers().find(p => p.id === providerId) || null;
});
readonly selectedAuthMethod = computed(() => {
const methodId = this.draft().authMethod;
return this.authMethods().find(m => m.id === methodId) || null;
});
readonly deploymentTemplate = computed(() => this.buildDeploymentTemplate());
readonly copySafetyGuidance = computed(() => this.getCopySafetyGuidance());
readonly canGoNext = computed(() => { readonly canGoNext = computed(() => {
const step = this.currentStep(); switch (this.currentStep()) {
const d = this.draft();
switch (step) {
case 'provider': case 'provider':
return d.provider !== null; return this.draft().provider !== null;
case 'auth': case 'auth':
return this.isAuthValid(); return this.isConnectionValid();
case 'scope': case 'scope':
return this.isScopeValid(); return this.isScopeValid();
case 'schedule': case 'schedule':
@@ -111,169 +88,150 @@ export class IntegrationWizardComponent {
case 'preflight': case 'preflight':
return this.isPreflightPassed(); return this.isPreflightPassed();
case 'review': case 'review':
return d.name.trim().length > 0; return this.draft().name.trim().length > 0;
default: default:
return false; return false;
} }
}); });
readonly canGoBack = computed(() => this.currentStepIndex() > 0); private readonly syncTypeAndProviderSelection = effect(() => {
const integrationType = this.integrationType();
const providers = this.supportedProviders();
const currentProvider = this.draft().provider;
readonly scheduleOptions = [ this.draft.update((draft) => (
{ value: 'manual', label: 'Manual', description: 'Trigger scans manually or via API' }, draft.type === integrationType
{ value: 'interval', label: 'Interval', description: 'Run at fixed intervals' }, ? draft
{ value: 'cron', label: 'Cron', description: 'Use cron expression for scheduling' }, : { ...draft, type: integrationType }
]; ));
readonly intervalOptions = [ if (providers.length === 1 && currentProvider === null) {
{ value: 15, label: '15 minutes' }, this.selectProvider(providers[0].provider);
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
{ value: 360, label: '6 hours' },
{ value: 720, label: '12 hours' },
{ value: 1440, label: '24 hours' },
];
ngOnInit(): void {
// Apply preselected provider if provided
if (this.preselectedProvider()) {
this.selectProvider(this.preselectedProvider()!);
this.currentStep.set('auth'); this.currentStep.set('auth');
} }
});
// Set type from input selectProvider(provider: IntegrationProviderDefinition['provider']): void {
this.draft.update(d => ({ ...d, type: this.integrationType() })); const definition = this.supportedProviders().find((item) => item.provider === provider) ?? null;
} if (definition === null) {
return;
selectProvider(providerId: IntegrationProvider): void {
const provider = this.providers().find(p => p.id === providerId);
if (provider) {
this.draft.update(d => ({
...d,
provider: providerId,
name: d.name || `${provider.name} Integration`,
}));
} }
}
selectAuthMethod(methodId: string): void { this.draft.update((draft) => ({
this.draft.update(d => ({ ...draft,
...d, provider,
authMethod: methodId, type: this.integrationType(),
authValues: {}, name: draft.name || `${definition.name} ${this.getTypeLabel()} Integration`,
endpoint: draft.endpoint || definition.defaultEndpoint,
organizationId: draft.organizationId,
extendedConfig: this.ensureConfigDefaults(draft.extendedConfig, definition),
})); }));
} }
updateAuthValue(fieldId: string, value: string): void { updateName(value: string): void {
this.draft.update(d => ({ this.draft.update((draft) => ({ ...draft, name: value }));
...d, }
authValues: { ...d.authValues, [fieldId]: value },
updateEndpoint(value: string): void {
this.draft.update((draft) => ({ ...draft, endpoint: value }));
}
updateAuthRefUri(value: string): void {
this.draft.update((draft) => ({ ...draft, authRefUri: value }));
}
updateOrganizationId(value: string): void {
this.draft.update((draft) => ({ ...draft, organizationId: value }));
}
updateConfigField(fieldId: string, value: string): void {
this.draft.update((draft) => ({
...draft,
extendedConfig: {
...draft.extendedConfig,
[fieldId]: value,
},
})); }));
} }
updateScope<K extends keyof IntegrationDraft['scope']>( parseListInput(field: 'repositories' | 'branches' | 'namespaces' | 'tagPatterns', rawValue: string): void {
key: K, const items = rawValue
value: IntegrationDraft['scope'][K] .split('\n')
): void { .map((value) => value.trim())
this.draft.update(d => ({ .filter((value) => value.length > 0);
...d,
scope: { ...d.scope, [key]: value },
}));
}
parseScopeInput(field: keyof IntegrationDraft['scope'], rawValue: string): void { this.draft.update((draft) => ({
const parsed = rawValue.split('\n').filter(v => v.trim()); ...draft,
this.updateScope(field, parsed as IntegrationDraft['scope'][typeof field]); [field]: items,
}));
} }
updateSchedule<K extends keyof IntegrationDraft['schedule']>( updateSchedule<K extends keyof IntegrationDraft['schedule']>(
key: K, key: K,
value: IntegrationDraft['schedule'][K] value: IntegrationDraft['schedule'][K],
): void { ): void {
this.draft.update(d => ({ this.draft.update((draft) => ({
...d, ...draft,
schedule: { ...d.schedule, [key]: value }, schedule: { ...draft.schedule, [key]: value },
})); }));
} }
updateName(name: string): void {
this.draft.update(d => ({ ...d, name }));
}
toggleWebhook(): void { toggleWebhook(): void {
this.draft.update(d => ({ this.draft.update((draft) => ({
...d, ...draft,
webhookEnabled: !d.webhookEnabled, webhookEnabled: !draft.webhookEnabled,
webhookSecret: d.webhookEnabled ? undefined : this.generateWebhookSecret(),
})); }));
} }
addTag(): void {
const tag = this.newTag().trim();
if (tag && !this.draft().tags.includes(tag)) {
this.draft.update(d => ({ ...d, tags: [...d.tags, tag] }));
this.newTag.set('');
}
}
removeTag(tag: string): void {
this.draft.update(d => ({ ...d, tags: d.tags.filter(t => t !== tag) }));
}
onTagInput(event: Event): void { onTagInput(event: Event): void {
this.newTag.set((event.target as HTMLInputElement).value); this.newTag.set((event.target as HTMLInputElement).value);
} }
async runPreflightChecks(): Promise<void> { addTag(): void {
this.preflightRunning.set(true); const tag = this.newTag().trim();
const checks: PreflightCheck[] = this.getPreflightChecks(); if (tag.length === 0 || this.draft().tags.includes(tag)) {
this.preflightChecks.set(checks.map(c => ({ ...c, status: 'pending' }))); return;
// Deterministic sequential preflight checks.
for (let i = 0; i < checks.length; i++) {
this.preflightChecks.update(list =>
list.map((c, idx) => idx === i ? { ...c, status: 'running' } : c)
);
await this.waitForPreflightTick();
const result = this.evaluatePreflightResult(checks[i]);
this.preflightChecks.update(list =>
list.map((c, idx) => idx === i ? {
...c,
status: result.status,
message: result.message,
} : c)
);
} }
this.preflightRunning.set(false); this.draft.update((draft) => ({ ...draft, tags: [...draft.tags, tag] }));
this.newTag.set('');
}
removeTag(tag: string): void {
this.draft.update((draft) => ({
...draft,
tags: draft.tags.filter((item) => item !== tag),
}));
} }
goNext(): void { goNext(): void {
if (!this.canGoNext()) return; if (!this.canGoNext()) {
const idx = this.currentStepIndex(); return;
if (idx < this.steps.length - 1) { }
const nextStep = this.steps[idx + 1];
this.currentStep.set(nextStep);
// Auto-run preflight checks when entering preflight step const nextIndex = this.currentStepIndex() + 1;
if (nextStep === 'preflight' && this.preflightChecks().length === 0) { if (nextIndex >= this.steps.length) {
this.runPreflightChecks(); return;
} }
const nextStep = this.steps[nextIndex];
this.currentStep.set(nextStep);
if (nextStep === 'preflight' && this.preflightChecks().length === 0) {
void this.runPreflightChecks();
} }
} }
goBack(): void { goBack(): void {
if (!this.canGoBack()) return; if (!this.canGoBack()) {
const idx = this.currentStepIndex(); return;
if (idx > 0) {
this.currentStep.set(this.steps[idx - 1]);
} }
this.currentStep.set(this.steps[this.currentStepIndex() - 1]);
} }
goToStep(step: WizardStep): void { goToStep(step: WizardStep): void {
const targetIdx = this.steps.indexOf(step); const targetIndex = this.steps.indexOf(step);
if (targetIdx <= this.currentStepIndex()) { if (targetIndex <= this.currentStepIndex()) {
this.currentStep.set(step); this.currentStep.set(step);
} }
} }
@@ -283,269 +241,189 @@ export class IntegrationWizardComponent {
} }
onSubmit(): void { onSubmit(): void {
if (this.canGoNext()) { const request = buildCreateIntegrationRequest(this.draft());
this.create.emit(this.draft()); if (request === null) {
return;
}
this.create.emit(request);
}
async runPreflightChecks(): Promise<void> {
const checks = this.getPreflightChecks();
this.preflightRunning.set(true);
this.preflightChecks.set(checks.map((check) => ({ ...check, status: 'pending' })));
for (let index = 0; index < checks.length; index += 1) {
this.preflightChecks.update((items) =>
items.map((item, itemIndex) =>
itemIndex === index ? { ...item, status: 'running' } : item),
);
await Promise.resolve();
const result = this.evaluatePreflightResult(checks[index]);
this.preflightChecks.update((items) =>
items.map((item, itemIndex) =>
itemIndex === index
? { ...item, status: result.status, message: result.message }
: item),
);
}
this.preflightRunning.set(false);
}
getStepLabel(step: WizardStep): string {
switch (step) {
case 'provider':
return 'Provider';
case 'auth':
return 'Connection';
case 'scope':
return 'Scope';
case 'schedule':
return 'Schedule';
case 'preflight':
return 'Preflight';
case 'review':
return 'Review';
default:
return step;
}
}
getTypeLabel(): string {
switch (this.integrationType()) {
case 'registry':
return 'Registry';
case 'scm':
return 'SCM';
case 'ci':
return 'CI/CD';
case 'host':
return 'Host';
default:
return 'Integration';
} }
} }
copyToClipboard(text: string): void { copyToClipboard(text: string): void {
navigator.clipboard.writeText(text); void navigator.clipboard.writeText(text);
} }
private async waitForPreflightTick(): Promise<void> { private ensureConfigDefaults(
await Promise.resolve(); existing: Record<string, string>,
} definition: IntegrationProviderDefinition,
): Record<string, string> {
private evaluatePreflightResult( const defaults = { ...existing };
check: PreflightCheck for (const field of definition.configFields) {
): { status: 'success' | 'warning' | 'error'; message: string } { defaults[field.id] = defaults[field.id] ?? '';
const draft = this.draft();
switch (check.id) {
case 'auth':
if (this.isAuthValid()) {
return { status: 'success', message: 'Required credentials are present.' };
}
return { status: 'error', message: 'Missing required authentication values.' };
case 'connectivity':
if (draft.provider) {
return { status: 'success', message: 'Provider endpoint is reachable.' };
}
return { status: 'error', message: 'Select a provider before testing connectivity.' };
case 'list-repos':
if ((draft.scope.repositories?.length ?? 0) > 0) {
return { status: 'success', message: 'Repository scope is explicitly defined.' };
}
return { status: 'warning', message: 'No repository filter defined; full scope will be used.' };
case 'pull-manifest':
return draft.provider
? { status: 'success', message: 'Manifest pull capability validated.' }
: { status: 'error', message: 'Provider is required before manifest validation.' };
case 'webhook':
return draft.webhookEnabled
? { status: 'success', message: 'Webhook trigger is enabled.' }
: { status: 'warning', message: 'Webhook disabled; scheduled runs will be used.' };
case 'permissions':
case 'token-scope':
return this.isAuthValid()
? { status: 'success', message: 'Token permissions satisfy minimum requirements.' }
: { status: 'error', message: 'Token scope cannot be verified until auth is valid.' };
case 'workflow-access':
if ((draft.scope.repositories?.length ?? 0) > 0 || (draft.scope.organizations?.length ?? 0) > 0) {
return { status: 'success', message: 'Pipeline scope is configured.' };
}
return { status: 'warning', message: 'No pipeline scope configured; defaults will apply.' };
case 'kernel':
case 'btf':
case 'privileges':
case 'probe-bundle':
return { status: 'success', message: 'Host prerequisites validated from selected installer profile.' };
default:
return { status: 'success', message: 'Check passed.' };
} }
return defaults;
} }
private isAuthValid(): boolean { private isConnectionValid(): boolean {
const d = this.draft(); const definition = this.selectedProvider();
if (!d.authMethod) return false; const draft = this.draft();
if (definition === null) {
return false;
}
const method = this.selectedAuthMethod(); if (draft.endpoint.trim().length === 0 || draft.authRefUri.trim().length === 0) {
if (!method) return false; return false;
}
return method.fields return definition.configFields
.filter(f => f.required) .filter((field) => field.required)
.every(f => d.authValues[f.id]?.trim().length > 0); .every((field) => (draft.extendedConfig[field.id] ?? '').trim().length > 0);
} }
private isScopeValid(): boolean { private isScopeValid(): boolean {
const scope = this.draft().scope; const draft = this.draft();
// At least one scope field should be defined return (
return !!( draft.organizationId.trim().length > 0 ||
(scope.repositories && scope.repositories.length > 0) || draft.repositories.length > 0 ||
(scope.organizations && scope.organizations.length > 0) || draft.branches.length > 0 ||
(scope.namespaces && scope.namespaces.length > 0) || draft.namespaces.length > 0 ||
(scope.branches && scope.branches.length > 0) draft.tagPatterns.length > 0
); );
} }
private isScheduleValid(): boolean { private isScheduleValid(): boolean {
const schedule = this.draft().schedule; const schedule = this.draft().schedule;
switch (schedule.type) { switch (schedule.type) {
case 'manual': return true; case 'manual':
case 'interval': return (schedule.intervalMinutes ?? 0) > 0; return true;
case 'cron': return (schedule.cronExpression ?? '').trim().length > 0; case 'interval':
default: return false; return (schedule.intervalMinutes ?? 0) > 0;
case 'cron':
return (schedule.cronExpression ?? '').trim().length > 0;
default:
return false;
} }
} }
private isPreflightPassed(): boolean { private isPreflightPassed(): boolean {
const checks = this.preflightChecks(); const checks = this.preflightChecks();
if (checks.length === 0) return false; return checks.length > 0 && checks.every((check) => check.status === 'success' || check.status === 'warning');
return checks.every(c => c.status === 'success' || c.status === 'warning');
} }
private getPreflightChecks(): PreflightCheck[] { private getPreflightChecks(): PreflightCheck[] {
const type = this.integrationType(); const provider = this.selectedProvider();
const common: PreflightCheck[] = [ const checks: PreflightCheck[] = [
{ id: 'auth', name: 'Authentication', description: 'Verify credentials', status: 'pending' }, { id: 'authref', name: 'Credential indirection', description: 'Verify the connector uses an AuthRef URI instead of raw secrets.', status: 'pending' },
{ id: 'connectivity', name: 'Connectivity', description: 'Test network connection', status: 'pending' }, { id: 'endpoint', name: 'Endpoint contract', description: 'Confirm the endpoint matches the provider health/test contract.', status: 'pending' },
{ id: 'scope', name: 'Discovery scope', description: 'Ensure at least one repository, namespace, branch, or owner scope is set.', status: 'pending' },
{ id: 'schedule', name: 'Probe schedule', description: 'Validate the deterministic check cadence for this connector.', status: 'pending' },
]; ];
switch (type) { if (provider?.provider === 200) {
case 'registry': checks.push({ id: 'github-app', name: 'GitHub App metadata', description: 'App ID and Installation ID are present as non-secret config.', status: 'pending' });
return [ }
...common,
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' }, if (provider?.provider === 100) {
{ id: 'pull-manifest', name: 'Pull Manifest', description: 'Test manifest access', status: 'pending' }, checks.push({ id: 'harbor-route', name: 'Harbor health route', description: 'Harbor endpoints must answer /api/v2.0/health.', status: 'pending' });
]; }
case 'scm':
return [ return checks;
...common, }
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' },
{ id: 'webhook', name: 'Webhook Setup', description: 'Verify webhook configuration', status: 'pending' }, private evaluatePreflightResult(
{ id: 'permissions', name: 'Permissions', description: 'Check required permissions', status: 'pending' }, check: PreflightCheck,
]; ): { status: 'success' | 'warning' | 'error'; message: string } {
case 'ci': const draft = this.draft();
return [ const provider = this.selectedProvider();
...common,
{ id: 'token-scope', name: 'Token Scope', description: 'Verify token permissions', status: 'pending' }, switch (check.id) {
{ id: 'workflow-access', name: 'Workflow Access', description: 'Check workflow trigger access', status: 'pending' }, case 'authref':
]; return draft.authRefUri.trim().startsWith('authref://')
case 'host': ? { status: 'success', message: 'Credential indirection is configured via AuthRef URI.' }
return [ : { status: 'error', message: 'Use an authref:// URI instead of embedding secrets in the connector.' };
{ id: 'kernel', name: 'Kernel Version', description: 'Check kernel compatibility', status: 'pending' }, case 'endpoint':
{ id: 'btf', name: 'BTF Support', description: 'Verify BTF availability', status: 'pending' }, return draft.endpoint.trim().length > 0
{ id: 'privileges', name: 'Privileges', description: 'Check required privileges', status: 'pending' }, ? { status: 'success', message: `Endpoint ${draft.endpoint.trim()} will be used for connector probes.` }
{ id: 'probe-bundle', name: 'Probe Bundle', description: 'Verify probe availability', status: 'pending' }, : { status: 'error', message: 'A provider endpoint is required.' };
]; case 'scope':
return this.isScopeValid()
? { status: 'success', message: 'Connector scope is explicitly defined.' }
: { status: 'error', message: 'Set an owner, repository, namespace, branch, or tag scope before creating the connector.' };
case 'schedule':
return this.isScheduleValid()
? { status: 'success', message: `Connector checks will run in ${draft.schedule.type} mode.` }
: { status: 'error', message: 'The selected schedule is incomplete.' };
case 'github-app':
return ['appId', 'installationId'].every((field) => (draft.extendedConfig[field] ?? '').trim().length > 0)
? { status: 'success', message: 'GitHub App ID and Installation ID are present.' }
: { status: 'error', message: 'GitHub App onboarding requires App ID and Installation ID.' };
case 'harbor-route':
return draft.endpoint.trim().length > 0
? { status: 'warning', message: 'Harbor probes expect /api/v2.0/health on the configured endpoint.' }
: { status: 'error', message: 'Harbor requires a base endpoint before the health contract can be evaluated.' };
default: default:
return common; return provider
} ? { status: 'success', message: `${provider.name} onboarding draft is internally consistent.` }
} : { status: 'error', message: 'Select a supported provider first.' };
private generateWebhookSecret(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
}
private buildDeploymentTemplate(): string | null {
if (this.integrationType() !== 'host') {
return null;
}
const method = this.draft().authMethod;
if (method === 'helm') {
return this.buildHelmTemplate();
}
if (method === 'systemd') {
return this.buildSystemdTemplate();
}
if (method === 'offline') {
return this.buildOfflineBundleInstructions();
}
return null;
}
private buildHelmTemplate(): string {
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
const namespace = this.sanitizeIdentifier(this.draft().authValues['namespace'] || 'stellaops');
const valuesOverride = (this.draft().authValues['valuesOverride'] || '').trim();
let template = [
'# Deploy Zastava observer with Helm',
'helm repo add stellaops https://charts.stellaops.local',
'helm repo update',
'',
`helm upgrade --install ${integrationName} stellaops/zastava-observer \\`,
` --namespace ${namespace} --create-namespace \\`,
' --set integration.type=host \\',
` --set integration.name=${integrationName} \\`,
' --set-string stella.apiToken=${STELLA_API_TOKEN}',
].join('\n');
if (valuesOverride.length > 0) {
template += `\n# Optional values override\n# ${valuesOverride}`;
}
return template;
}
private buildSystemdTemplate(): string {
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
const installPath = (this.draft().authValues['installPath'] || '/opt/stellaops').trim();
const safeInstallPath = installPath.length > 0 ? installPath : '/opt/stellaops';
return [
'# Install Zastava observer with systemd',
`sudo mkdir -p ${safeInstallPath}/bin`,
`sudo install -m 0755 ./zastava-observer ${safeInstallPath}/bin/zastava-observer`,
'',
'cat <<\'UNIT\' | sudo tee /etc/systemd/system/zastava-observer.service',
'[Unit]',
`Description=StellaOps Zastava Observer (${integrationName})`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`WorkingDirectory=${safeInstallPath}`,
'Environment=STELLA_API_URL=https://stella.local',
'Environment=STELLA_API_TOKEN=${STELLA_API_TOKEN}',
`ExecStart=${safeInstallPath}/bin/zastava-observer --integration ${integrationName}`,
'Restart=on-failure',
'RestartSec=5',
'',
'[Install]',
'WantedBy=multi-user.target',
'UNIT',
'',
'sudo systemctl daemon-reload',
'sudo systemctl enable --now zastava-observer',
].join('\n');
}
private buildOfflineBundleInstructions(): string {
return [
'# Offline bundle deployment',
'1. Download the latest signed offline bundle from StellaOps.',
'2. Transfer the bundle to the target host through approved media.',
'3. Verify signatures with `stella verify-bundle --path ./bundle.tar.zst`.',
'4. Run `stella install-offline --bundle ./bundle.tar.zst --token ${STELLA_API_TOKEN}`.',
].join('\n');
}
private getCopySafetyGuidance(): string[] {
return [
'Use placeholder variables in shared docs, never real secrets.',
'Store tokens in environment variables or a secret manager.',
'Rotate credentials immediately if they are exposed in logs or chat.',
];
}
private sanitizeIdentifier(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'stellaops-host';
}
getStepLabel(step: WizardStep): string {
switch (step) {
case 'provider': return 'Provider';
case 'auth': return 'Authentication';
case 'scope': return 'Scope';
case 'schedule': return 'Schedule';
case 'preflight': return 'Preflight';
case 'review': return 'Review';
default: return step;
}
}
getTypeLabel(): string {
switch (this.integrationType()) {
case 'registry': return 'Registry';
case 'scm': return 'SCM';
case 'ci': return 'CI/CD';
case 'host': return 'Host';
default: return 'Integration';
} }
} }
} }

View File

@@ -1,21 +1,16 @@
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { IntegrationWizardComponent } from './integration-wizard.component'; import { timeout } from 'rxjs';
import { IntegrationService } from '../integration-hub/integration.service';
import { CreateIntegrationRequest, SupportedProviderInfo } from '../integration-hub/integration.models';
import { integrationWorkspaceCommands } from '../integration-hub/integration-route-context'; import { integrationWorkspaceCommands } from '../integration-hub/integration-route-context';
import { IntegrationWizardComponent } from './integration-wizard.component';
import { import {
IntegrationType, IntegrationOnboardingType,
IntegrationDraft, resolveSupportedProviders,
REGISTRY_PROVIDERS,
SCM_PROVIDERS,
CI_PROVIDERS,
HOST_PROVIDERS,
} from './models/integration.models'; } from './models/integration.models';
/**
* Integrations Hub Page (Sprint: SPRINT_20251229_014)
* Central page for managing all integrations with wizard access.
*/
@Component({ @Component({
selector: 'app-integrations-hub', selector: 'app-integrations-hub',
standalone: true, standalone: true,
@@ -25,77 +20,97 @@ import {
@if (!activeWizard()) { @if (!activeWizard()) {
<header class="hub-header"> <header class="hub-header">
<h1>Integrations</h1> <h1>Integrations</h1>
<p>Connect StellaOps to your registries, SCM providers, CI/CD pipelines, and hosts.</p> <p>Connect StellaOps to the providers that are actually installed in this environment.</p>
</header> </header>
<div class="integration-categories"> @if (loadingCatalog()) {
<!-- Registry Integrations --> <section class="catalog-state" role="status">
<section class="category-section"> <h2>Loading provider catalog</h2>
<div class="category-header"> <p>Reading the installed connector plugins from the integrations service.</p>
<h2>Container Registries</h2>
<button class="btn btn-primary" (click)="openWizard('registry')">
+ Add Registry
</button>
</div>
<p class="category-desc">Connect container registries for automated image scanning.</p>
<div class="provider-pills">
@for (p of registryProviders; track p.id) {
<span class="provider-pill">{{ p.name }}</span>
}
</div>
</section> </section>
} @else if (loadError()) {
<section class="catalog-state error-state" role="status">
<h2>Connector catalog unavailable</h2>
<p>{{ loadError() }}</p>
<button class="btn btn-primary" type="button" (click)="loadProviderCatalog()">Retry</button>
</section>
} @else {
<div class="integration-categories">
<section class="category-section">
<div class="category-header">
<div>
<h2>Container Registries</h2>
<p class="category-desc">Connect container registries for image discovery, probing, and policy handoff.</p>
</div>
<button class="btn btn-primary" type="button" (click)="openWizard('registry')" [disabled]="registryProviders().length === 0">
+ Add Registry
</button>
</div>
@if (registryProviders().length > 0) {
<div class="provider-pills">
@for (provider of registryProviders(); track provider.provider) {
<span class="provider-pill">{{ provider.name }}</span>
}
</div>
} @else {
<p class="category-empty">No registry connector plugins are installed in this environment.</p>
}
</section>
<!-- SCM Integrations --> <section class="category-section">
<section class="category-section"> <div class="category-header">
<div class="category-header"> <div>
<h2>Source Control</h2> <h2>Source Control</h2>
<button class="btn btn-primary" (click)="openWizard('scm')"> <p class="category-desc">Connect repository hosts using credential indirection and deterministic discovery scope.</p>
+ Add SCM </div>
</button> <button class="btn btn-primary" type="button" (click)="openWizard('scm')" [disabled]="scmProviders().length === 0">
</div> + Add SCM
<p class="category-desc">Connect SCM providers for repository and webhook integration.</p> </button>
<div class="provider-pills"> </div>
@for (p of scmProviders; track p.id) { @if (scmProviders().length > 0) {
<span class="provider-pill">{{ p.name }}</span> <div class="provider-pills">
@for (provider of scmProviders(); track provider.provider) {
<span class="provider-pill">{{ provider.name }}</span>
}
</div>
} @else {
<p class="category-empty">No SCM connector plugins are installed in this environment.</p>
} }
</div> </section>
</section>
<!-- CI/CD Integrations --> <section class="category-section">
<section class="category-section"> <div class="category-header">
<div class="category-header"> <div>
<h2>CI/CD Pipelines</h2> <h2>CI/CD Pipelines</h2>
<button class="btn btn-primary" (click)="openWizard('ci')"> <p class="category-desc">CI onboarding stays unavailable until a real pipeline connector plugin is installed.</p>
+ Add CI/CD </div>
</button> <button class="btn btn-primary" type="button" (click)="openWizard('ci')" [disabled]="ciProviders().length === 0">
</div> + Add CI/CD
<p class="category-desc">Integrate with CI/CD platforms for pipeline-triggered scans.</p> </button>
<div class="provider-pills"> </div>
@for (p of ciProviders; track p.id) { <p class="category-empty">No CI/CD connector plugins are currently available.</p>
<span class="provider-pill">{{ p.name }}</span> </section>
}
</div>
</section>
<!-- Host Integrations --> <section class="category-section">
<section class="category-section"> <div class="category-header">
<div class="category-header"> <div>
<h2>Hosts & Observers</h2> <h2>Hosts & Observers</h2>
<button class="btn btn-primary" (click)="openWizard('host')"> <p class="category-desc">Runtime-host onboarding stays unavailable until a real host connector plugin is installed.</p>
+ Add Host </div>
</button> <button class="btn btn-primary" type="button" (click)="openWizard('host')" [disabled]="hostProviders().length === 0">
</div> + Add Host
<p class="category-desc">Deploy Zastava observers for runtime signal collection.</p> </button>
<div class="provider-pills"> </div>
@for (p of hostProviders; track p.id) { <p class="category-empty">No runtime-host connector plugins are currently available.</p>
<span class="provider-pill">{{ p.name }}</span> </section>
} </div>
</div> }
</section>
</div>
} @else { } @else {
<app-integration-wizard <app-integration-wizard
[integrationType]="activeWizard()!" [integrationType]="activeWizard()!"
[supportedProviders]="providersForType(activeWizard()!)"
[creating]="saving()"
[errorMessage]="saveError()"
(cancel)="closeWizard()" (cancel)="closeWizard()"
(create)="onIntegrationCreated($event)" (create)="onIntegrationCreated($event)"
/> />
@@ -111,17 +126,36 @@ import {
.hub-header { .hub-header {
margin-bottom: 2rem; margin-bottom: 2rem;
}
h1 { .hub-header h1 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
p { .hub-header p {
margin: 0; margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} max-width: 56rem;
}
.catalog-state {
display: grid;
gap: 0.75rem;
padding: 1.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
}
.catalog-state h2,
.catalog-state p {
margin: 0;
}
.error-state {
border-color: var(--color-status-error-bg);
} }
.integration-categories { .integration-categories {
@@ -134,39 +168,42 @@ import {
padding: 1.5rem; padding: 1.5rem;
background: var(--color-surface-secondary); background: var(--color-surface-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
}
.category-header { .category-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; gap: 1rem;
margin-bottom: 0.5rem; align-items: start;
margin-bottom: 0.75rem;
}
h2 { .category-header h2 {
margin: 0; margin: 0 0 0.35rem;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
}
.category-desc { .category-desc,
margin: 0 0 1rem; .category-empty {
color: var(--color-text-secondary); margin: 0;
font-size: 0.875rem; color: var(--color-text-secondary);
} font-size: 0.875rem;
}
.provider-pills { .provider-pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.provider-pill { .provider-pill {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
background: var(--color-surface-tertiary); background: var(--color-surface-tertiary);
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
}
} }
.btn { .btn {
@@ -175,14 +212,21 @@ import {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
border: none; border: none;
}
&.btn-primary { .btn.btn-primary {
background: var(--color-brand-primary); background: var(--color-brand-primary);
color: var(--color-text-heading); color: var(--color-text-heading);
}
&:hover { .btn:disabled {
background: var(--color-brand-primary-hover); opacity: 0.5;
} cursor: not-allowed;
}
@media (max-width: 720px) {
.category-header {
flex-direction: column;
} }
} }
`], `],
@@ -191,62 +235,122 @@ import {
export class IntegrationsHubComponent implements OnInit { export class IntegrationsHubComponent implements OnInit {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly integrationService = inject(IntegrationService);
private readonly requestTimeoutMs = 12_000;
readonly activeWizard = signal<IntegrationType | null>(null); readonly activeWizard = signal<IntegrationOnboardingType | null>(null);
readonly supportedCatalog = signal<readonly SupportedProviderInfo[]>([]);
readonly loadingCatalog = signal(true);
readonly loadError = signal<string | null>(null);
readonly saving = signal(false);
readonly saveError = signal<string | null>(null);
readonly registryProviders = REGISTRY_PROVIDERS; readonly registryProviders = computed(() => resolveSupportedProviders('registry', this.supportedCatalog()));
readonly scmProviders = SCM_PROVIDERS; readonly scmProviders = computed(() => resolveSupportedProviders('scm', this.supportedCatalog()));
readonly ciProviders = CI_PROVIDERS; readonly ciProviders = computed(() => resolveSupportedProviders('ci', this.supportedCatalog()));
readonly hostProviders = HOST_PROVIDERS; readonly hostProviders = computed(() => resolveSupportedProviders('host', this.supportedCatalog()));
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((params) => { this.route.paramMap.subscribe((params) => {
const type = params.get('type'); const type = params.get('type');
this.activeWizard.set(this.parseWizardType(type)); this.activeWizard.set(this.parseWizardType(type));
}); });
this.loadProviderCatalog();
} }
openWizard(type: IntegrationType): void { loadProviderCatalog(): void {
this.loadingCatalog.set(true);
this.loadError.set(null);
this.integrationService.getSupportedProviders().pipe(
timeout({ first: this.requestTimeoutMs }),
).subscribe({
next: (catalog) => {
this.supportedCatalog.set(catalog);
this.loadingCatalog.set(false);
},
error: (err) => {
this.supportedCatalog.set([]);
this.loadingCatalog.set(false);
this.loadError.set(
err?.name === 'TimeoutError'
? 'The integrations service did not return its provider catalog in time. Retry or verify the frontdoor path.'
: 'The integrations service provider catalog could not be loaded.',
);
},
});
}
openWizard(type: IntegrationOnboardingType): void {
if (this.providersForType(type).length === 0) {
return;
}
this.activeWizard.set(type); this.activeWizard.set(type);
void this.router.navigate(this.integrationCommands('onboarding', type)); this.saveError.set(null);
void this.router.navigate(this.integrationCommands('onboarding', type), {
queryParamsHandling: 'merge',
});
} }
closeWizard(): void { closeWizard(): void {
this.activeWizard.set(null); this.activeWizard.set(null);
void this.router.navigate(this.integrationCommands('onboarding')); this.saveError.set(null);
this.saving.set(false);
void this.router.navigate(this.integrationCommands('onboarding'), {
queryParamsHandling: 'merge',
});
} }
onIntegrationCreated(draft: IntegrationDraft): void { onIntegrationCreated(request: CreateIntegrationRequest): void {
this.closeWizard(); this.saving.set(true);
void this.router.navigate(this.integrationCommands(this.getIntegrationListPath(draft.type))); this.saveError.set(null);
this.integrationService.create(request).pipe(
timeout({ first: this.requestTimeoutMs }),
).subscribe({
next: (created) => {
this.saving.set(false);
void this.router.navigate(this.integrationCommands(created.id), {
queryParamsHandling: 'merge',
});
},
error: (err) => {
this.saving.set(false);
this.saveError.set(
err?.name === 'TimeoutError'
? 'Creating the integration timed out before the service responded.'
: 'The integration could not be created. Verify the provider fields and try again.',
);
},
});
} }
private parseWizardType(type: string | null): IntegrationType | null { providersForType(type: IntegrationOnboardingType) {
switch (type) { switch (type) {
case 'registry': case 'registry':
return 'registry'; return this.registryProviders();
case 'scm': case 'scm':
return 'scm'; return this.scmProviders();
case 'ci': case 'ci':
return 'ci'; return this.ciProviders();
case 'host': case 'host':
return 'host'; return this.hostProviders();
default: default:
return null; return [];
} }
} }
private getIntegrationListPath(type: IntegrationType | null): string { private parseWizardType(type: string | null): IntegrationOnboardingType | null {
switch (type) { switch (type) {
case 'scm':
return 'scm';
case 'ci':
return 'ci';
case 'host':
return 'runtime-hosts';
case 'registry': case 'registry':
case 'scm':
case 'ci':
case 'host':
return type;
default: default:
return 'registries'; return null;
} }
} }

View File

@@ -1,52 +1,34 @@
/** import {
* Integration types and wizard models (Sprint: SPRINT_20251229_014) CreateIntegrationRequest,
*/ IntegrationProvider,
IntegrationType,
export type IntegrationProvider = SupportedProviderInfo,
| 'docker-hub' } from '../../integration-hub/integration.models';
| 'harbor'
| 'ecr'
| 'acr'
| 'gcr'
| 'ghcr'
| 'github'
| 'gitlab'
| 'gitea'
| 'github-actions'
| 'gitlab-ci'
| 'gitea-actions'
| 'kubernetes'
| 'vm'
| 'baremetal';
export type IntegrationType = 'registry' | 'scm' | 'ci' | 'host';
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host';
export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review'; export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review';
export interface IntegrationProviderInfo { export interface ProviderField {
id: IntegrationProvider;
name: string;
type: IntegrationType;
icon: string;
description: string;
docsUrl?: string;
}
export interface AuthMethod {
id: string; id: string;
name: string; label: string;
description: string;
fields: AuthField[];
}
export interface AuthField {
id: string;
name: string;
type: 'text' | 'password' | 'select' | 'checkbox';
required: boolean; required: boolean;
placeholder?: string; placeholder?: string;
hint?: string; hint?: string;
options?: { value: string; label: string }[]; }
export interface IntegrationProviderDefinition {
provider: IntegrationProvider;
type: IntegrationOnboardingType;
name: string;
icon: string;
description: string;
defaultEndpoint: string;
endpointHint: string;
authRefHint: string;
organizationLabel?: string;
organizationHint?: string;
exposeInUi: boolean;
configFields: ProviderField[];
} }
export interface PreflightCheck { export interface PreflightCheck {
@@ -60,149 +42,183 @@ export interface PreflightCheck {
export interface IntegrationDraft { export interface IntegrationDraft {
name: string; name: string;
provider: IntegrationProvider | null; provider: IntegrationProvider | null;
type: IntegrationType | null; type: IntegrationOnboardingType | null;
authMethod: string | null; endpoint: string;
authValues: Record<string, string>; authRefUri: string;
scope: IntegrationScope; organizationId: string;
repositories: string[];
branches: string[];
namespaces: string[];
tagPatterns: string[];
schedule: IntegrationSchedule; schedule: IntegrationSchedule;
webhookEnabled: boolean; webhookEnabled: boolean;
webhookSecret?: string;
tags: string[]; tags: string[];
} extendedConfig: Record<string, string>;
export interface IntegrationScope {
repositories?: string[];
branches?: string[];
organizations?: string[];
namespaces?: string[];
tagPatterns?: string[];
environments?: string[];
} }
export interface IntegrationSchedule { export interface IntegrationSchedule {
type: 'manual' | 'interval' | 'cron'; type: 'manual' | 'interval' | 'cron';
intervalMinutes?: number; intervalMinutes?: number;
cronExpression?: string; cronExpression?: string;
timezone?: string;
} }
export const REGISTRY_PROVIDERS: IntegrationProviderInfo[] = [ const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [
{ id: 'docker-hub', name: 'Docker Hub', type: 'registry', icon: 'D', description: 'Public and private Docker registries' }, {
{ id: 'harbor', name: 'Harbor', type: 'registry', icon: 'H', description: 'Self-hosted Harbor registry' }, provider: IntegrationProvider.Harbor,
{ id: 'ecr', name: 'Amazon ECR', type: 'registry', icon: 'A', description: 'AWS Elastic Container Registry' }, type: 'registry',
{ id: 'acr', name: 'Azure ACR', type: 'registry', icon: 'Z', description: 'Azure Container Registry' }, name: 'Harbor',
{ id: 'gcr', name: 'Google GCR', type: 'registry', icon: 'G', description: 'Google Container Registry / Artifact Registry' }, icon: 'H',
{ id: 'ghcr', name: 'GitHub GHCR', type: 'registry', icon: 'GH', description: 'GitHub Container Registry' }, description: 'Self-hosted Harbor registry with robot-account or basic-auth health probes.',
]; defaultEndpoint: 'https://harbor.local',
endpointHint: 'Use the Harbor base URL; StellaOps probes /api/v2.0/health.',
authRefHint: 'Reference a robot-account or username:password secret, for example authref://vault/harbor#robot-account.',
organizationLabel: 'Project / Namespace',
organizationHint: 'Optional Harbor project scope used for list and policy views.',
exposeInUi: true,
configFields: [],
},
{
provider: IntegrationProvider.GitHubApp,
type: 'scm',
name: 'GitHub App',
icon: 'GH',
description: 'GitHub App installation with App ID and Installation ID stored as non-secret config.',
defaultEndpoint: 'https://github.com',
endpointHint: 'Use https://github.com for GitHub Cloud or your GitHub Enterprise Server base URL.',
authRefHint: 'Reference a vault secret that resolves to the app JWT or installation token.',
organizationLabel: 'Owner / Organization',
organizationHint: 'Optional owner used to scope repository discovery and policy views.',
exposeInUi: true,
configFields: [
{
id: 'appId',
label: 'GitHub App ID',
required: true,
placeholder: '123456',
},
{
id: 'installationId',
label: 'Installation ID',
required: true,
placeholder: '7890123',
},
],
},
{
provider: IntegrationProvider.InMemory,
type: 'registry',
name: 'In-Memory',
icon: 'IM',
description: 'Deterministic connector used for internal testing only.',
defaultEndpoint: 'http://inmemory.local',
endpointHint: 'Internal-only testing connector.',
authRefHint: 'Internal-only testing connector.',
exposeInUi: false,
configFields: [],
},
] as const;
export const SCM_PROVIDERS: IntegrationProviderInfo[] = [ export const REGISTRY_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'registry');
{ id: 'github', name: 'GitHub', type: 'scm', icon: 'GH', description: 'GitHub repositories and organizations' }, export const SCM_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'scm');
{ id: 'gitlab', name: 'GitLab', type: 'scm', icon: 'GL', description: 'GitLab projects and groups' }, export const CI_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'ci');
{ id: 'gitea', name: 'Gitea', type: 'scm', icon: 'GT', description: 'Self-hosted Gitea repositories' }, export const HOST_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'host');
];
export const CI_PROVIDERS: IntegrationProviderInfo[] = [ export const scheduleOptions = [
{ id: 'github-actions', name: 'GitHub Actions', type: 'ci', icon: 'GH', description: 'GitHub Actions workflows' }, { value: 'manual', label: 'Manual', description: 'Create the connector now and trigger checks on demand.' },
{ id: 'gitlab-ci', name: 'GitLab CI', type: 'ci', icon: 'GL', description: 'GitLab CI/CD pipelines' }, { value: 'interval', label: 'Interval', description: 'Run connector checks at a fixed interval.' },
{ id: 'gitea-actions', name: 'Gitea Actions', type: 'ci', icon: 'GT', description: 'Gitea Actions workflows' }, { value: 'cron', label: 'Cron', description: 'Use a cron expression when the platform should probe or sync.' },
]; ] as const;
export const HOST_PROVIDERS: IntegrationProviderInfo[] = [ export const intervalOptions = [
{ id: 'kubernetes', name: 'Kubernetes', type: 'host', icon: 'K8', description: 'Kubernetes cluster with Helm/DaemonSet' }, { value: 15, label: '15 minutes' },
{ id: 'vm', name: 'Virtual Machine', type: 'host', icon: 'VM', description: 'VM with systemd service' }, { value: 30, label: '30 minutes' },
{ id: 'baremetal', name: 'Bare Metal', type: 'host', icon: 'BM', description: 'Bare metal server with agent' }, { value: 60, label: '1 hour' },
]; { value: 360, label: '6 hours' },
{ value: 720, label: '12 hours' },
{ value: 1440, label: '24 hours' },
] as const;
export const AUTH_METHODS: Record<IntegrationType, AuthMethod[]> = { export function resolveSupportedProviders(
registry: [ type: IntegrationOnboardingType,
{ catalog: readonly SupportedProviderInfo[],
id: 'basic', ): IntegrationProviderDefinition[] {
name: 'Username & Password', const supportedProviderIds = new Set(catalog.map((item) => item.provider));
description: 'Basic authentication with registry credentials',
fields: [ return ALL_PROVIDER_DEFINITIONS
{ id: 'username', name: 'Username', type: 'text', required: true, placeholder: 'Registry username' }, .filter((provider) => provider.type === type && provider.exposeInUi && supportedProviderIds.has(provider.provider));
{ id: 'password', name: 'Password', type: 'password', required: true, placeholder: 'Registry password or token' }, }
],
}, export function resolveProviderDefinition(provider: IntegrationProvider | null): IntegrationProviderDefinition | null {
{ if (provider === null) {
id: 'token', return null;
name: 'Access Token', }
description: 'Token-based authentication',
fields: [ return ALL_PROVIDER_DEFINITIONS.find((item) => item.provider === provider && item.exposeInUi) ?? null;
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'Registry access token' }, }
],
}, export function toBackendIntegrationType(type: IntegrationOnboardingType | null): IntegrationType | null {
{ switch (type) {
id: 'aws-iam', case 'registry':
name: 'AWS IAM Role', return IntegrationType.Registry;
description: 'Authenticate using AWS IAM role', case 'scm':
fields: [ return IntegrationType.Scm;
{ id: 'region', name: 'AWS Region', type: 'text', required: true, placeholder: 'us-east-1' }, case 'ci':
{ id: 'roleArn', name: 'Role ARN', type: 'text', required: false, placeholder: 'arn:aws:iam::...:role/...' }, return IntegrationType.CiCd;
], case 'host':
}, return IntegrationType.RuntimeHost;
], default:
scm: [ return null;
{ }
id: 'github-app', }
name: 'GitHub App',
description: 'Recommended: Install a GitHub App for fine-grained permissions', export function buildCreateIntegrationRequest(draft: IntegrationDraft): CreateIntegrationRequest | null {
fields: [ const backendType = toBackendIntegrationType(draft.type);
{ id: 'appId', name: 'App ID', type: 'text', required: true, placeholder: 'GitHub App ID' }, if (backendType === null || draft.provider === null) {
{ id: 'installationId', name: 'Installation ID', type: 'text', required: true, placeholder: 'Installation ID' }, return null;
{ id: 'privateKey', name: 'Private Key', type: 'password', required: true, hint: 'PEM-encoded private key' }, }
],
}, const extendedConfig: Record<string, unknown> = {};
{
id: 'pat', for (const [key, value] of Object.entries(draft.extendedConfig)) {
name: 'Personal Access Token', const trimmedValue = value.trim();
description: 'Use a personal access token with repo scope', if (trimmedValue.length > 0) {
fields: [ extendedConfig[key] = trimmedValue;
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'ghp_..., glpat-..., or Gitea token' }, }
], }
},
], if (draft.repositories.length > 0) {
ci: [ extendedConfig['repositories'] = draft.repositories;
{ }
id: 'oidc', if (draft.branches.length > 0) {
name: 'OIDC Token Exchange', extendedConfig['branches'] = draft.branches;
description: 'Recommended: Use OIDC for keyless authentication', }
fields: [ if (draft.namespaces.length > 0) {
{ id: 'audience', name: 'Audience', type: 'text', required: true, placeholder: 'stellaops' }, extendedConfig['namespaces'] = draft.namespaces;
], }
}, if (draft.tagPatterns.length > 0) {
{ extendedConfig['tagPatterns'] = draft.tagPatterns;
id: 'token', }
name: 'Service Token',
description: 'Use a service account token', extendedConfig['scheduleType'] = draft.schedule.type;
fields: [ if (draft.schedule.type === 'interval' && draft.schedule.intervalMinutes) {
{ id: 'token', name: 'Service Token', type: 'password', required: true, placeholder: 'Service account token' }, extendedConfig['intervalMinutes'] = draft.schedule.intervalMinutes;
], }
}, if (draft.schedule.type === 'cron' && draft.schedule.cronExpression) {
], extendedConfig['cronExpression'] = draft.schedule.cronExpression;
host: [ }
{ if (draft.webhookEnabled) {
id: 'helm', extendedConfig['webhookEnabled'] = true;
name: 'Helm Chart', }
description: 'Deploy agent using Helm chart',
fields: [ return {
{ id: 'namespace', name: 'Namespace', type: 'text', required: true, placeholder: 'stellaops' }, name: draft.name.trim(),
{ id: 'valuesOverride', name: 'Values Override', type: 'text', required: false, hint: 'YAML values to override' }, description: null,
], type: backendType,
}, provider: draft.provider,
{ endpoint: draft.endpoint.trim(),
id: 'systemd', authRefUri: draft.authRefUri.trim() || null,
name: 'Systemd Service', organizationId: draft.organizationId.trim() || null,
description: 'Install agent as systemd service', extendedConfig: Object.keys(extendedConfig).length > 0 ? extendedConfig : null,
fields: [ tags: draft.tags.length > 0 ? draft.tags : null,
{ id: 'installPath', name: 'Install Path', type: 'text', required: true, placeholder: '/opt/stellaops' }, };
], }
},
{
id: 'offline',
name: 'Offline Bundle',
description: 'Download offline bundle for air-gapped deployment',
fields: [],
},
],
};

View File

@@ -1,3 +1,15 @@
import 'zone.js';
import 'zone.js/testing';
import { readFile, readdir } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { getTestBed } from '@angular/core/testing';
import { ɵresolveComponentResources as resolveComponentResources } from '@angular/core';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
/** /**
* Jasmine-to-Vitest compatibility shim. * Jasmine-to-Vitest compatibility shim.
* *
@@ -8,7 +20,82 @@
* *
* This file bridges those APIs so existing specs work without mass refactoring. * This file bridges those APIs so existing specs work without mass refactoring.
*/ */
import { vi, type Mock } from 'vitest'; import { beforeEach, vi, type Mock } from 'vitest';
const angularTestEnvironmentKey = '__stellaAngularTestEnvironmentInitialized__';
const angularTestResourceRoot = join(process.cwd(), 'src');
const angularTestResourcePathCache = new Map<string, string>();
const angularTestResourceContentCache = new Map<string, string>();
async function findAngularTestResourcePath(
searchRoot: string,
targetFileName: string,
): Promise<string | null> {
const entries = await readdir(searchRoot, { withFileTypes: true });
for (const entry of entries) {
const candidate = join(searchRoot, entry.name);
if (entry.isDirectory()) {
const nestedMatch = await findAngularTestResourcePath(candidate, targetFileName);
if (nestedMatch !== null) {
return nestedMatch;
}
continue;
}
if (entry.isFile() && entry.name === targetFileName) {
return candidate;
}
}
return null;
}
async function resolveAngularTestResource(url: string): Promise<string> {
const normalizedUrl = url.replace(/\\/g, '/');
const cachedContent = angularTestResourceContentCache.get(normalizedUrl);
if (cachedContent !== undefined) {
return cachedContent;
}
const targetFileName = basename(normalizedUrl);
let resourcePath = angularTestResourcePathCache.get(targetFileName) ?? null;
if (resourcePath === null) {
resourcePath = await findAngularTestResourcePath(angularTestResourceRoot, targetFileName);
if (resourcePath === null) {
throw new Error(`Unable to resolve Angular test resource: ${url}`);
}
angularTestResourcePathCache.set(targetFileName, resourcePath);
}
const content = await readFile(resourcePath, 'utf8');
angularTestResourceContentCache.set(normalizedUrl, content);
return content;
}
if (!(globalThis as Record<string, unknown>)[angularTestEnvironmentKey]) {
try {
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
{
teardown: { destroyAfterEach: true },
},
);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (!message.includes('Cannot set base providers because it has already been called')) {
throw error;
}
}
(globalThis as Record<string, unknown>)[angularTestEnvironmentKey] = true;
}
beforeEach(async () => {
await resolveComponentResources(resolveAngularTestResource);
getTestBed().resetTestingModule();
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// jasmine.createSpy → vi.fn // jasmine.createSpy → vi.fn

View File

@@ -1,127 +1,125 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { IntegrationsHubComponent } from '../../app/features/integrations/integrations-hub.component'; import { IntegrationsHubComponent } from '../../app/features/integrations/integrations-hub.component';
import { IntegrationType } from '../../app/features/integrations/models/integration.models';
import { IntegrationWizardComponent } from '../../app/features/integrations/integration-wizard.component'; import { IntegrationWizardComponent } from '../../app/features/integrations/integration-wizard.component';
import { IntegrationService } from '../../app/features/integration-hub/integration.service';
import {
CreateIntegrationRequest,
IntegrationProvider,
IntegrationType,
SupportedProviderInfo,
} from '../../app/features/integration-hub/integration.models';
describe('Integration Onboarding Wizard (integration_hub)', () => { @Component({
describe('IntegrationsHubComponent route wiring', () => { selector: 'app-integration-wizard',
let fixture: ComponentFixture<IntegrationsHubComponent>; standalone: true,
let component: IntegrationsHubComponent; template: '',
let router: Router; })
class IntegrationWizardStubComponent {
readonly integrationType = input.required<'registry' | 'scm' | 'ci' | 'host'>();
readonly supportedProviders = input<readonly SupportedProviderInfo[]>([]);
readonly creating = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly create = output<CreateIntegrationRequest>();
}
beforeEach(async () => { describe('IntegrationsHubComponent onboarding flow', () => {
await TestBed.configureTestingModule({ let fixture: ComponentFixture<IntegrationsHubComponent>;
imports: [IntegrationsHubComponent], let component: IntegrationsHubComponent;
providers: [ let router: Router;
provideRouter([]), let integrationService: jasmine.SpyObj<IntegrationService>;
{
provide: ActivatedRoute, beforeEach(async () => {
useValue: { integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['getSupportedProviders', 'create']);
paramMap: of(convertToParamMap({ type: 'registry' })), integrationService.getSupportedProviders.and.returnValue(of([
}, { name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
]));
await TestBed.configureTestingModule({
imports: [IntegrationsHubComponent],
providers: [
provideRouter([]),
{ provide: IntegrationService, useValue: integrationService },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ type: null })),
}, },
], },
}).compileComponents(); ],
})
.overrideComponent(IntegrationsHubComponent, {
remove: { imports: [IntegrationWizardComponent] },
add: { imports: [IntegrationWizardStubComponent] },
})
.compileComponents();
router = TestBed.inject(Router); router = TestBed.inject(Router);
fixture = TestBed.createComponent(IntegrationsHubComponent); fixture = TestBed.createComponent(IntegrationsHubComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
});
it('activates wizard from onboarding route param', () => {
expect(component.activeWizard()).toBe('registry');
});
it('navigates to typed onboarding route when opening wizard', () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.openWizard('host');
expect(component.activeWizard()).toBe('host');
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding', 'host']);
});
it('returns to onboarding root when wizard closes', () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.closeWizard();
expect(component.activeWizard()).toBeNull();
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding']);
});
}); });
describe('IntegrationWizardComponent deterministic behavior', () => { it('shows only providers that are backed by installed plugins', () => {
let fixture: ComponentFixture<IntegrationWizardComponent>; expect(component.registryProviders().map((provider) => provider.name)).toEqual(['Harbor']);
let component: IntegrationWizardComponent; expect(component.scmProviders().map((provider) => provider.name)).toEqual(['GitHub App']);
expect(component.ciProviders()).toEqual([]);
expect(component.hostProviders()).toEqual([]);
});
async function createWizard(type: IntegrationType): Promise<void> { it('navigates to typed onboarding only when the category is supported', async () => {
await TestBed.configureTestingModule({ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
imports: [IntegrationWizardComponent],
}).compileComponents();
fixture = TestBed.createComponent(IntegrationWizardComponent); component.openWizard('registry');
fixture.componentRef.setInput('integrationType', type); component.openWizard('ci');
component = fixture.componentInstance;
fixture.detectChanges();
}
it('produces deterministic preflight check results across reruns', async () => { expect(navigateSpy).toHaveBeenCalledOnceWith(['/ops/integrations', 'onboarding', 'registry'], {
await createWizard('registry'); queryParamsHandling: 'merge',
component.selectProvider('docker-hub'); });
component.selectAuthMethod('token'); expect(component.activeWizard()).toBe('registry');
component.updateAuthValue('token', 'qa-token'); });
component.parseScopeInput('repositories', 'team/api');
await component.runPreflightChecks(); it('creates the integration and routes to the created detail page', () => {
const firstRun = component.preflightChecks().map((check) => ({ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
id: check.id, integrationService.create.and.returnValue(of({
status: check.status, id: 'int-42',
message: check.message, name: 'QA Harbor',
})); description: null,
type: IntegrationType.Registry,
provider: IntegrationProvider.Harbor,
status: 0,
endpoint: 'https://harbor.example.com',
hasAuth: true,
organizationId: 'platform',
lastHealthStatus: 0,
lastHealthCheckAt: null,
createdAt: '2026-03-14T10:00:00Z',
updatedAt: '2026-03-14T10:00:00Z',
createdBy: 'demo-user',
updatedBy: 'demo-user',
tags: ['qa'],
}));
await component.runPreflightChecks(); component.onIntegrationCreated({
const secondRun = component.preflightChecks().map((check) => ({ name: 'QA Harbor',
id: check.id, description: null,
status: check.status, type: IntegrationType.Registry,
message: check.message, provider: IntegrationProvider.Harbor,
})); endpoint: 'https://harbor.example.com',
authRefUri: 'authref://vault/harbor#robot',
expect(secondRun).toEqual(firstRun); organizationId: 'platform',
extendedConfig: null,
tags: ['qa'],
}); });
it('generates copy-safe Helm deployment template for host onboarding', async () => { expect(integrationService.create).toHaveBeenCalled();
await createWizard('host'); expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'int-42'], {
component.selectProvider('kubernetes'); queryParamsHandling: 'merge',
component.selectAuthMethod('helm');
component.updateAuthValue('namespace', 'stellaops');
component.updateName('Prod Host Observer');
const template = component.deploymentTemplate();
const guidance = component.copySafetyGuidance();
expect(template).toContain('helm upgrade --install');
expect(template).toContain('--namespace stellaops');
expect(template).toContain('STELLA_API_TOKEN');
expect(guidance.join(' ')).toContain('environment variables');
});
it('generates systemd template with placeholder token and service units', async () => {
await createWizard('host');
component.selectProvider('vm');
component.selectAuthMethod('systemd');
component.updateAuthValue('installPath', '/opt/stellaops');
component.updateName('VM Host Observer');
const template = component.deploymentTemplate();
expect(template).toContain('[Unit]');
expect(template).toContain('Environment=STELLA_API_TOKEN=${STELLA_API_TOKEN}');
expect(template).toContain('systemctl enable --now zastava-observer');
}); });
}); });
}); });