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
- **Source:** https://github.com/moby/moby
#### NGINX
- **License:** BSD-2-Clause
- **Source:** https://nginx.org/
- **Usage:** Optional local QA integration fixtures for retained Harbor and GitHub App onboarding checks
#### Kubernetes
- **License:** Apache-2.0
- **Source:** https://github.com/kubernetes/kubernetes

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` |
| Add observability | `docker compose -f docker-compose.stella-ops.yml -f docker-compose.telemetry.yml up -d` |
| Start QA integration fixtures | `docker compose -f docker-compose.integration-fixtures.yml up -d` |
| Run CI/testing infrastructure | `docker compose -f docker-compose.testing.yml --profile ci up -d` |
| Deploy with China compliance | See [China Compliance](#china-compliance-sm2sm3sm4) |
| Deploy with Russia compliance | See [Russia Compliance](#russia-compliance-gost) |
@@ -22,6 +23,7 @@ Consolidated Docker Compose configuration for the StellaOps platform. All profil
| File | Purpose |
|------|---------|
| `docker-compose.stella-ops.yml` | **Main stack**: PostgreSQL 18.1, Valkey 9.0.1, RustFS, Rekor v2, all StellaOps services |
| `docker-compose.integration-fixtures.yml` | **QA success fixtures**: Harbor and GitHub App API fixtures for retained onboarding verification |
| `docker-compose.telemetry.yml` | **Observability**: OpenTelemetry collector, Prometheus, Tempo, Loki |
| `docker-compose.testing.yml` | **CI/Testing**: Test databases, mock services, Gitea for integration tests |
| `docker-compose.dev.yml` | **Minimal dev infrastructure**: PostgreSQL, Valkey, RustFS only |
@@ -203,6 +205,33 @@ docker compose -f docker-compose.testing.yml --profile all up -d
---
### QA Integration Success Fixtures
Use the fixture compose lane when you need a scratch stack to prove successful Integrations Hub onboarding for the providers currently exposed in the local UI (`Harbor`, `GitHub App`).
```bash
# Start the fixture services after the main stack is up
docker compose -f docker-compose.integration-fixtures.yml up -d
# Harbor success-path endpoint
curl http://harbor-fixture.stella-ops.local/api/v2.0/health
# GitHub App success-path endpoints
curl http://github-app-fixture.stella-ops.local/api/v3/app
curl http://github-app-fixture.stella-ops.local/api/v3/rate_limit
```
Fixture endpoints:
| Service | Hostname | Port | Contract |
|---------|----------|------|----------|
| Harbor fixture | `harbor-fixture.stella-ops.local` | 80 | `GET /api/v2.0/health` -> healthy JSON + `X-Harbor-Version` |
| GitHub App fixture | `github-app-fixture.stella-ops.local` | 80 | `GET /api/v3/app`, `GET /api/v3/rate_limit` |
These fixtures are deterministic QA aids only; they are not production dependencies and remain opt-in.
---
## Regional Compliance Deployments
### China Compliance (SM2/SM3/SM4)

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.4 rekor.stella-ops.local
127.1.1.5 registry.stella-ops.local
127.1.1.6 harbor-fixture.stella-ops.local
127.1.1.7 github-app-fixture.stella-ops.local

View File

@@ -40,6 +40,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config
```powershell
.\scripts\setup.ps1 # full setup
.\scripts\setup.ps1 -InfraOnly # infrastructure only (PostgreSQL, Valkey, RustFS, Rekor, Zot)
.\scripts\setup.ps1 -QaIntegrationFixtures # full setup plus Harbor/GitHub App QA fixtures
```
**Linux / macOS:**
@@ -47,6 +48,7 @@ The fastest way to get running. The setup scripts validate prerequisites, config
```bash
./scripts/setup.sh # full setup
./scripts/setup.sh --infra-only # infrastructure only
./scripts/setup.sh --qa-integration-fixtures # full setup plus Harbor/GitHub App QA fixtures
```
The scripts will:
@@ -57,6 +59,7 @@ The scripts will:
5. Create or reuse the external frontdoor Docker network from `.env` (`FRONTDOOR_NETWORK`, default `stellaops_frontdoor`)
6. Stop repo-local host-run Stella services that would lock build outputs, then build repo-owned .NET solutions and publish backend services locally into small Docker contexts before building hardened runtime images (vendored or generated trees such as `node_modules`, `dist`, `coverage`, and `output` are excluded)
7. Launch the full platform with health checks, perform one bounded restart pass for services that stay unhealthy after first boot, wait for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), then complete an authenticated convergence gate that proves topology inventory, notifications administration overrides, and promotion bootstrap flows load cleanly before reporting success
8. If `-QaIntegrationFixtures` / `--qa-integration-fixtures` is enabled, start deterministic Harbor and GitHub App fixtures and verify them so the local Integrations Hub can be exercised with successful UI onboarding
Open **https://stella-ops.local** when setup completes.
@@ -85,6 +88,8 @@ Stella Ops services bind to unique loopback IPs so all can use port 443 without
Runtime URL convention remains `*.stella-ops.local`; `hosts.stellaops.local` is the template file name only.
The same template also carries the optional `harbor-fixture.stella-ops.local` and `github-app-fixture.stella-ops.local` aliases used by the fixture-backed integrations QA lane.
- **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator)
- **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'`

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 -SkipImages # build .NET but skip Docker images
.\scripts\setup.ps1 -ImagesOnly # only build Docker images
.\scripts\setup.ps1 -QaIntegrationFixtures # full setup plus Harbor/GitHub App QA fixtures for real UI onboarding checks
```
**Linux / macOS:**
@@ -27,9 +28,10 @@ Setup scripts validate prerequisites, build solutions and Docker images, and lau
./scripts/setup.sh --skip-build # skip .NET builds
./scripts/setup.sh --skip-images # skip Docker image builds
./scripts/setup.sh --images-only # only build Docker images
./scripts/setup.sh --qa-integration-fixtures # full setup plus Harbor/GitHub App QA fixtures
```
The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, waits for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), and then runs an authenticated readiness probe that proves the topology inventory, notifications administration overrides, and promotion bootstrap routes load cleanly before the script prints success. See the manual steps below for details on each stage.
The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, waits for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), and then runs an authenticated readiness probe that proves the topology inventory, notifications administration overrides, and promotion bootstrap routes load cleanly before the script prints success. When `-QaIntegrationFixtures` / `--qa-integration-fixtures` is enabled, setup also starts deterministic Harbor and GitHub App fixtures and smoke-checks them so the Integrations Hub can be verified with successful UI onboarding, not just failure-path cards. See the manual steps below for details on each stage.
On Windows and Linux, the backend image builder now publishes each selected .NET service locally and builds the hardened runtime image from a small temporary context. That avoids repeatedly streaming the whole monorepo into Docker during scratch setup.
@@ -121,7 +123,7 @@ Full details: [`docs/technical/architecture/port-registry.md`](../technical/arch
### Automated (recommended)
The setup scripts (`scripts/setup.ps1` / `scripts/setup.sh`) will detect missing entries and offer to install them automatically.
The setup scripts (`scripts/setup.ps1` / `scripts/setup.sh`) will detect missing entries and offer to install them automatically. The host template now also includes `harbor-fixture.stella-ops.local` and `github-app-fixture.stella-ops.local` for the optional fixture-backed integrations QA lane.
### Manual

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 |
| npm (Runtime) | ~15 | MIT, Apache-2.0, ISC, 0BSD |
| npm (Dev) | ~30+ | MIT, Apache-2.0 |
| Infrastructure | 6 | PostgreSQL, MPL-2.0, BSD-3-Clause, Apache-2.0 |
| Infrastructure | 7 | PostgreSQL, MPL-2.0, BSD-2-Clause, BSD-3-Clause, Apache-2.0 |
### Canonical License Declarations
@@ -290,6 +290,7 @@ Components required for deployment but not bundled with StellaOps source.
| Valkey | ≥7.2 | BSD-3-Clause | BSD-3-Clause | Separate | Optional cache (Redis fork) for StellaOps and Rekor |
| Rekor v2 (rekor-tiles) | v2 (tiles) | Apache-2.0 | Apache-2.0 | Separate | Optional transparency log (POSIX tiles backend) |
| Docker | ≥24 | Apache-2.0 | Apache-2.0 | Tooling | Container runtime |
| NGINX | 1.27-alpine | BSD-2-Clause | BSD-2-Clause | Separate | Optional local QA fixture image for Harbor and GitHub App onboarding success-path checks |
| OCI Registry | - | Varies | - | External | Harbor (Apache-2.0), Docker Hub, etc. |
| Kubernetes | ≥1.28 | Apache-2.0 | Apache-2.0 | Orchestration | Optional |
| all-MiniLM-L6-v2 embedding model | - | Apache-2.0 | Apache-2.0 | Optional runtime asset | Local semantic embedding model for AdvisoryAI (`VectorEncoderType=onnx`) |

View File

@@ -96,6 +96,11 @@ public interface IIntegrationPlugin
- **Harbor** - Robot account authentication, project and repository enumeration
- **InMemory** - Deterministic test double for integration tests and offline development
### Provider endpoint contracts
- **GitHub App** - Operators provide either the GitHub Cloud root (`https://github.com`), a GitHub Enterprise Server root, or an explicit `/api/v3` base. The connector normalizes the endpoint to a single API root and probes relative `app` / `rate_limit` paths so GitHub Enterprise onboarding never falls back to origin-root `/app`.
- **Harbor** - Operators provide the Harbor base URL. Stella Ops probes the provider-specific `/api/v2.0/health` route for connection tests and health checks.
## Security Considerations
- **AuthRef URI credential model:** Credentials are stored in an external vault (e.g., HashiCorp Vault, Azure Key Vault). The integration catalog stores only the URI reference (`authref://vault/path/to/secret`), never the raw secret.

View File

@@ -8,7 +8,9 @@ param(
[string]$ImplId = (Get-Date).ToUniversalTime().ToString('yyyyMMdd'),
[int]$StartingBatchId = 0
[int]$StartingBatchId = 0,
[bool]$QaIntegrationFixtures = $true
)
Set-StrictMode -Version Latest
@@ -400,6 +402,9 @@ for ($iteration = $StartIteration; $iteration -le $EndIteration; $iteration++) {
$state.Decisions.Add('Decision: each scratch iteration remains a full wipe -> setup -> route/page/action audit -> grouped remediation loop; if the audit comes back clean, that still counts as a completed iteration because the full loop was executed.')
$state.Decisions.Add('Decision: changed or newly discovered user flows must be converted into retained Playwright coverage before the next scratch iteration starts so the audit surface expands instead of rediscovering the same gaps manually.')
$state.Decisions.Add('Risk: scratch rebuilds remain expensive, so verification stays Playwright-first with focused test/build slices rather than indiscriminate full-solution test runs.')
if ($QaIntegrationFixtures) {
$state.Decisions.Add('Decision: scratch setup starts the Harbor and GitHub App QA fixtures so the full audit proves real success-path integration onboarding from the UI, not just graceful failure handling.')
}
$state.NextCheckpoints.Add('Finish the Stella-only wipe and capture the next zero-state setup outcome.')
$state.NextCheckpoints.Add('Run the full Playwright route/page/action audit, including changed-surface and ownership checks, on the rebuilt stack before diagnosing any new fixes.')
Add-SprintLog -State $state -Update "Sprint created for the next scratch iteration after local commit ``$baselineCommit`` closed the previous clean baseline." -Owner 'QA'
@@ -413,12 +418,18 @@ for ($iteration = $StartIteration; $iteration -le $EndIteration; $iteration++) {
Add-SprintLog -State $state -Update 'Removed Stella-only containers, `stellaops/*:dev` images, Stella compose volumes, and the `stellaops` / `stellaops_frontdoor` networks to return the machine to zero Stella runtime state for the new iteration.' -Owner 'QA / 3rd line support'
Write-SprintState -State $state -Path $sprintPath
Invoke-External -FilePath (Join-Path $repoRoot 'scripts/setup.ps1') -WorkingDirectory $repoRoot
$setupArguments = @()
if ($QaIntegrationFixtures) {
$setupArguments += '-QaIntegrationFixtures'
}
Invoke-External -FilePath (Join-Path $repoRoot 'scripts/setup.ps1') -ArgumentList $setupArguments -WorkingDirectory $repoRoot
$state.Criteria12 = $true
$state.Criteria13 = $true
$state.Status1 = 'DONE'
$healthySummary = Get-HealthyContainerSummary
Add-SprintLog -State $state -Update "The zero-state setup rerun completed cleanly: ``36/36`` solution builds passed, the full image matrix rebuilt, platform services converged, and ``$healthySummary`` Stella containers are healthy on ``https://stella-ops.local``." -Owner 'QA / 3rd line support'
$fixtureModeText = if ($QaIntegrationFixtures) { ' with Harbor/GitHub App QA fixtures enabled' } else { '' }
Add-SprintLog -State $state -Update "The zero-state setup rerun completed cleanly$fixtureModeText: ``36/36`` solution builds passed, the full image matrix rebuilt, platform services converged, and ``$healthySummary`` Stella containers are healthy on ``https://stella-ops.local``." -Owner 'QA / 3rd line support'
$state.Status2 = 'DOING'
Write-SprintState -State $state -Path $sprintPath

View File

@@ -13,13 +13,16 @@
Only build Docker images (skip infra start and .NET build).
.PARAMETER SkipImages
Skip Docker image builds.
.PARAMETER QaIntegrationFixtures
Start the optional Harbor and GitHub App QA fixtures used for successful Integrations Hub onboarding checks.
#>
[CmdletBinding()]
param(
[switch]$SkipBuild,
[switch]$InfraOnly,
[switch]$ImagesOnly,
[switch]$SkipImages
[switch]$SkipImages,
[switch]$QaIntegrationFixtures
)
$ErrorActionPreference = 'Stop'
@@ -391,7 +394,7 @@ function Test-Prerequisites {
# ─── 2. Check and install hosts file ─────────────────────────────────────
function Test-HostsFile {
Write-Step 'Checking hosts file for stella-ops.local entries'
Write-Step 'Checking hosts file for required Stella Ops entries'
$hostsPath = 'C:\Windows\System32\drivers\etc\hosts'
$hostsSource = Join-Path $Root 'devops/compose/hosts.stellaops.local'
@@ -400,20 +403,49 @@ function Test-HostsFile {
return
}
$content = Get-Content $hostsPath -Raw
if ($content -match 'stella-ops\.local') {
Write-Ok 'stella-ops.local entries found in hosts file'
return
}
Write-Warn 'stella-ops.local entries NOT found in hosts file.'
if (-not (Test-Path $hostsSource)) {
Write-Warn "Hosts source file not found at $hostsSource"
Write-Host ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' -ForegroundColor Yellow
return
}
$content = Get-Content $hostsPath -Raw
$sourceLines = Get-Content $hostsSource | Where-Object {
$trimmed = $_.Trim()
$trimmed -and -not $trimmed.StartsWith('#')
}
$missingLines = @()
$missingHosts = @()
foreach ($line in $sourceLines) {
$tokens = $line -split '\s+' | Where-Object { $_ }
if ($tokens.Count -lt 2) {
continue
}
$hostnames = $tokens | Select-Object -Skip 1
$lineMissing = $false
foreach ($hostname in $hostnames) {
$escapedHostname = [regex]::Escape($hostname)
if ($content -notmatch "(?m)(^|\s)$escapedHostname($|\s)") {
$missingHosts += $hostname
$lineMissing = $true
}
}
if ($lineMissing) {
$missingLines += $line
}
}
if ($missingHosts.Count -eq 0) {
Write-Ok 'All required Stella Ops host entries are present in the hosts file'
return
}
$missingHosts = $missingHosts | Sort-Object -Unique
Write-Warn "Missing Stella Ops host aliases: $([string]::Join(', ', $missingHosts))"
# Check if running as Administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
@@ -424,9 +456,9 @@ function Test-HostsFile {
Write-Host ''
$answer = Read-Host ' Add entries to hosts file now? (Y/n)'
if ($answer -eq '' -or $answer -match '^[Yy]') {
$hostsBlock = Get-Content $hostsSource -Raw
Add-Content -Path $hostsPath -Value "`n$hostsBlock"
Write-Ok 'Hosts entries added successfully'
Add-Content -Path $hostsPath -Value ''
Add-Content -Path $hostsPath -Value ($missingLines -join [Environment]::NewLine)
Write-Ok "Added $($missingLines.Count) missing host entry line(s) successfully"
} else {
Write-Warn 'Skipped. Add them manually before accessing the platform.'
Write-Host " Copy from: $hostsSource" -ForegroundColor Yellow
@@ -597,6 +629,27 @@ function Start-Platform {
-restartAfterSeconds 45)
}
function Start-QaIntegrationFixtures {
Write-Step 'Starting QA integration fixtures'
Push-Location $ComposeDir
try {
docker compose -f docker-compose.integration-fixtures.yml up -d
if ($LASTEXITCODE -ne 0) {
Write-Fail 'Failed to start QA integration fixtures.'
exit 1
}
[void](Wait-ForComposeConvergence `
-composeFiles @('docker-compose.integration-fixtures.yml') `
-successMessage 'QA integration fixtures are healthy' `
-maxWaitSeconds 90 `
-restartAfterSeconds 30)
}
finally {
Pop-Location
}
}
function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) {
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
$statusCode = $null
@@ -770,6 +823,24 @@ function Test-Smoke {
$hasBlockingFailures = $true
}
if ($QaIntegrationFixtures) {
$harborFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.6/api/v2.0/health' @(200)
if ($null -ne $harborFixtureStatus) {
Write-Ok "Harbor QA fixture (HTTP $harborFixtureStatus)"
} else {
Write-Fail 'Harbor QA fixture did not respond with HTTP 200 on /api/v2.0/health'
$hasBlockingFailures = $true
}
$githubFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.7/api/v3/app' @(200)
if ($null -ne $githubFixtureStatus) {
Write-Ok "GitHub App QA fixture (HTTP $githubFixtureStatus)"
} else {
Write-Fail 'GitHub App QA fixture did not respond with HTTP 200 on /api/v3/app'
$hasBlockingFailures = $true
}
}
if (-not $InfraOnly) {
if (Test-FrontdoorBootstrap) {
Write-Ok 'Frontdoor bootstrap path is ready for first-user sign-in'
@@ -794,8 +865,15 @@ function Test-Smoke {
@('docker-compose.stella-ops.yml')
}
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
if (-not ($composeFiles | Where-Object { Test-Path $_ })) {
$composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.yml')
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
}
$totalContainers = 0
@@ -902,6 +980,9 @@ Initialize-EnvFile
if ($InfraOnly) {
Start-Infrastructure
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$infraSmokeFailed = Test-Smoke
if ($infraSmokeFailed) {
Write-Fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.'
@@ -920,6 +1001,9 @@ if (-not $SkipImages) {
}
Start-Platform
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$platformSmokeFailed = Test-Smoke
if ($platformSmokeFailed) {
Write-Fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.'
@@ -929,6 +1013,10 @@ if ($platformSmokeFailed) {
Write-Host "`n=============================================" -ForegroundColor Green
Write-Host ' Setup complete!' -ForegroundColor Green
Write-Host ' Platform: https://stella-ops.local' -ForegroundColor Green
if ($QaIntegrationFixtures) {
Write-Host ' Harbor QA fixture: http://harbor-fixture.stella-ops.local/api/v2.0/health' -ForegroundColor Green
Write-Host ' GitHub App QA fixture: http://github-app-fixture.stella-ops.local/api/v3/app' -ForegroundColor Green
}
Write-Host ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green
Write-Host '=============================================' -ForegroundColor Green
exit 0

View File

@@ -11,6 +11,7 @@ SKIP_BUILD=false
INFRA_ONLY=false
IMAGES_ONLY=false
SKIP_IMAGES=false
QA_INTEGRATION_FIXTURES=false
for arg in "$@"; do
case "$arg" in
@@ -18,8 +19,9 @@ for arg in "$@"; do
--infra-only) INFRA_ONLY=true ;;
--images-only) IMAGES_ONLY=true ;;
--skip-images) SKIP_IMAGES=true ;;
--qa-integration-fixtures) QA_INTEGRATION_FIXTURES=true ;;
-h|--help)
echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images]"
echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images] [--qa-integration-fixtures]"
exit 0
;;
*) echo "Unknown flag: $arg" >&2; exit 1 ;;
@@ -326,16 +328,9 @@ check_prerequisites() {
# ─── 2. Check and install hosts file ─────────────────────────────────────
check_hosts() {
step 'Checking hosts file for stella-ops.local entries'
step 'Checking hosts file for required Stella Ops entries'
local hosts_source="${ROOT}/devops/compose/hosts.stellaops.local"
if grep -q 'stella-ops\.local' /etc/hosts 2>/dev/null; then
ok 'stella-ops.local entries found in /etc/hosts'
return
fi
warn 'stella-ops.local entries NOT found in /etc/hosts.'
if [[ ! -f "$hosts_source" ]]; then
warn "Hosts source file not found at $hosts_source"
echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2'
@@ -343,6 +338,42 @@ check_hosts() {
return
fi
local source_lines=()
while IFS= read -r line; do
[[ -z "${line// }" ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
source_lines+=("$line")
done < "$hosts_source"
local missing_lines=()
local missing_hosts=()
local line hostname
for line in "${source_lines[@]}"; do
read -r -a tokens <<< "$line"
(( ${#tokens[@]} < 2 )) && continue
local line_missing=false
for hostname in "${tokens[@]:1}"; do
if ! grep -Eq "(^|[[:space:]])${hostname}($|[[:space:]])" /etc/hosts 2>/dev/null; then
missing_hosts+=("$hostname")
line_missing=true
fi
done
if [[ "$line_missing" == "true" ]]; then
missing_lines+=("$line")
fi
done
if (( ${#missing_hosts[@]} == 0 )); then
ok 'All required Stella Ops host entries are present in /etc/hosts'
return
fi
local unique_missing_hosts
unique_missing_hosts=$(printf '%s\n' "${missing_hosts[@]}" | awk 'NF { print }' | sort -u | paste -sd ', ' -)
warn "Missing Stella Ops host aliases: ${unique_missing_hosts}"
echo ''
echo ' Stella Ops needs ~50 hosts file entries for local development.'
echo " Source: devops/compose/hosts.stellaops.local"
@@ -353,21 +384,21 @@ check_hosts() {
if [[ -z "$answer" || "$answer" =~ ^[Yy] ]]; then
if [[ "$(id -u)" -eq 0 ]]; then
printf '\n' >> /etc/hosts
cat "$hosts_source" >> /etc/hosts
ok 'Hosts entries added successfully'
printf '%s\n' "${missing_lines[@]}" >> /etc/hosts
ok "Added ${#missing_lines[@]} missing host entry line(s) successfully"
else
echo ''
echo ' Adding hosts entries requires sudo...'
if sudo sh -c "printf '\n' >> /etc/hosts && cat '$hosts_source' >> /etc/hosts"; then
ok 'Hosts entries added successfully'
if printf '%s\n' "${missing_lines[@]}" | sudo tee -a /etc/hosts >/dev/null; then
ok "Added ${#missing_lines[@]} missing host entry line(s) successfully"
else
warn 'Failed to add hosts entries. Add them manually:'
echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'"
echo " printf '%s\n' \"${missing_lines[@]}\" | sudo tee -a /etc/hosts"
fi
fi
else
warn 'Skipped. Add them manually before accessing the platform:'
echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'"
printf ' %s\n' "${missing_lines[@]}"
fi
}
@@ -486,6 +517,15 @@ start_platform() {
wait_for_compose_convergence 'Platform services converged from zero-state startup' true 180 45 docker-compose.stella-ops.yml || true
}
start_qa_integration_fixtures() {
step 'Starting QA integration fixtures'
cd "$COMPOSE_DIR"
docker compose -f docker-compose.integration-fixtures.yml up -d
ok 'QA integration fixtures started'
cd "$ROOT"
wait_for_compose_convergence 'QA integration fixtures are healthy' false 90 30 docker-compose.integration-fixtures.yml || true
}
http_status() {
local url="$1"
local attempts="${2:-6}"
@@ -597,6 +637,25 @@ smoke_test() {
has_blocking_failures=true
fi
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
local harbor_fixture_status github_fixture_status
harbor_fixture_status=$(http_status 'http://127.1.1.6/api/v2.0/health')
if [[ "$harbor_fixture_status" == "200" ]]; then
ok "Harbor QA fixture (HTTP $harbor_fixture_status)"
else
warn 'Harbor QA fixture did not respond with HTTP 200 on /api/v2.0/health'
has_blocking_failures=true
fi
github_fixture_status=$(http_status 'http://127.1.1.7/api/v3/app')
if [[ "$github_fixture_status" == "200" ]]; then
ok "GitHub App QA fixture (HTTP $github_fixture_status)"
else
warn 'GitHub App QA fixture did not respond with HTTP 200 on /api/v3/app'
has_blocking_failures=true
fi
fi
if [[ "$INFRA_ONLY" != "true" ]]; then
if ! frontdoor_bootstrap_ready; then
has_blocking_failures=true
@@ -614,8 +673,19 @@ smoke_test() {
local total=0
local healthy=0
local unhealthy_names=""
local compose_files=()
for cf in docker-compose.dev.yml docker-compose.stella-ops.yml; do
if [[ "$INFRA_ONLY" == "true" ]]; then
compose_files+=(docker-compose.dev.yml)
else
compose_files+=(docker-compose.stella-ops.yml)
fi
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
compose_files+=(docker-compose.integration-fixtures.yml)
fi
for cf in "${compose_files[@]}"; do
[[ ! -f "$cf" ]] && continue
while IFS= read -r line; do
[[ -z "$line" ]] && continue
@@ -676,6 +746,9 @@ ensure_env
start_infra
if [[ "$INFRA_ONLY" == "true" ]]; then
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
start_qa_integration_fixtures
fi
if ! smoke_test; then
fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
@@ -698,6 +771,9 @@ if [[ "$SKIP_IMAGES" != "true" ]]; then
fi
start_platform
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
start_qa_integration_fixtures
fi
if ! smoke_test; then
fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
@@ -707,6 +783,10 @@ echo ''
echo '============================================='
echo ' Setup complete!'
echo ' Platform: https://stella-ops.local'
if [[ "$QA_INTEGRATION_FIXTURES" == "true" ]]; then
echo ' Harbor QA fixture: http://harbor-fixture.stella-ops.local/api/v2.0/health'
echo ' GitHub App QA fixture: http://github-app-fixture.stella-ops.local/api/v3/app'
fi
echo ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md'
echo '============================================='
exit 0

View File

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

View File

@@ -38,7 +38,7 @@ public sealed class IntegrationService
_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 integration = new Integration
@@ -78,9 +78,9 @@ public sealed class IntegrationService
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);
}
@@ -110,9 +110,9 @@ public sealed class IntegrationService
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;
var oldStatus = integration.Status;
@@ -153,9 +153,9 @@ public sealed class IntegrationService
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;
await _repository.DeleteAsync(id, cancellationToken);
@@ -172,9 +172,9 @@ public sealed class IntegrationService
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;
var plugin = _pluginLoader.GetByProvider(integration.Provider);
@@ -227,9 +227,9 @@ public sealed class IntegrationService
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;
var plugin = _pluginLoader.GetByProvider(integration.Provider);
@@ -269,9 +269,9 @@ public sealed class IntegrationService
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)
{
return null;
@@ -302,6 +302,27 @@ public sealed class IntegrationService
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)
{
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
{
// Call GitHub API to verify authentication
var response = await client.GetAsync("/app", cancellationToken);
var response = await client.GetAsync("app", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
@@ -98,7 +98,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
try
{
// Check GitHub API status
var response = await client.GetAsync("/rate_limit", cancellationToken);
var response = await client.GetAsync("rate_limit", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
@@ -151,9 +151,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com"
? "https://api.github.com"
: config.Endpoint.TrimEnd('/') + "/api/v3";
var baseUrl = ResolveBaseUrl(config.Endpoint);
var client = new HttpClient
{
@@ -174,6 +172,22 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
return client;
}
private static string ResolveBaseUrl(string? endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint) || endpoint.Equals("https://github.com", StringComparison.OrdinalIgnoreCase))
{
return "https://api.github.com/";
}
var normalized = endpoint.TrimEnd('/');
if (normalized.EndsWith("/api/v3", StringComparison.OrdinalIgnoreCase))
{
return normalized + "/";
}
return normalized + "/api/v3/";
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true

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>
<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="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
</ItemGroup>

View File

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

View File

@@ -37,6 +37,16 @@ const suites = [
script: 'live-integrations-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'),
},
{
name: 'integrations-onboarding-persistence-check',
script: 'live-integrations-onboarding-persistence-check.mjs',
reportPath: path.join(outputDir, 'live-integrations-onboarding-persistence-check.json'),
},
{
name: 'integrations-onboarding-success-fixtures-check',
script: 'live-integrations-onboarding-success-fixtures-check.mjs',
reportPath: path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.json'),
},
{
name: 'setup-topology-action-sweep',
script: 'live-setup-topology-action-sweep.mjs',

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 { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { IntegrationDetailComponent } from './integration-detail.component';
import { IntegrationService } from './integration.service';
import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models';
import {
HealthStatus,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
} from './integration.models';
describe('IntegrationDetailComponent', () => {
let component: IntegrationDetailComponent;
let fixture: ComponentFixture<IntegrationDetailComponent>;
let httpMock: HttpTestingController;
const mockIntegration: Integration = {
id: '1',
name: 'Harbor Registry',
type: IntegrationType.Registry,
provider: 'harbor',
status: IntegrationStatus.Active,
description: 'Main container registry',
tags: ['production'],
configuration: {
endpoint: 'https://harbor.example.com',
username: 'admin'
},
createdAt: '2025-12-29T12:00:00Z',
updatedAt: '2025-12-29T12:00:00Z',
createdBy: 'admin',
lastHealthCheck: '2025-12-29T11:55:00Z',
healthStatus: 'healthy'
};
let component: IntegrationDetailComponent;
let integrationService: jasmine.SpyObj<IntegrationService>;
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({
imports: [IntegrationDetailComponent],
providers: [
IntegrationService,
provideHttpClient(),
provideHttpClientTesting()
]
provideRouter([]),
{ provide: IntegrationService, useValue: integrationService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ integrationId: 'int-1' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(IntegrationDetailComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load integration details when integrationId is set', () => {
component.integrationId = '1';
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/integrations/1');
expect(req.request.method).toBe('GET');
req.flush(mockIntegration);
expect(component.integration).toEqual(mockIntegration);
});
it('should test connection successfully', fakeAsync(() => {
component.integration = mockIntegration;
fixture.detectChanges();
const testResult: ConnectionTestResult = {
success: true,
message: 'Connected successfully',
latencyMs: 45
};
it('loads canonical integration details and uses endpoint and health status fields', () => {
expect(component.integration?.id).toBe('int-1');
expect(component.integration?.endpoint).toBe('https://harbor.example.com');
expect(component.getHealthLabel(component.integration!.lastHealthStatus)).toBe('Healthy');
});
it('captures test connection results using backend message and duration fields', () => {
component.testConnection();
tick();
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
expect(req.request.method).toBe('POST');
req.flush(testResult);
tick();
expect(component.connectionTestResult).toEqual(testResult);
expect(component.isTestingConnection).toBeFalse();
}));
it('should handle test connection failure', fakeAsync(() => {
component.integration = mockIntegration;
fixture.detectChanges();
const testResult: ConnectionTestResult = {
success: false,
message: 'Connection refused',
error: 'ECONNREFUSED'
};
component.testConnection();
tick();
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
req.flush(testResult);
tick();
expect(component.connectionTestResult?.success).toBeFalse();
expect(component.connectionTestResult?.error).toBe('ECONNREFUSED');
}));
it('should enable integration', fakeAsync(() => {
const disabledIntegration = { ...mockIntegration, status: IntegrationStatus.Disabled };
component.integration = disabledIntegration;
fixture.detectChanges();
component.enableIntegration();
tick();
const req = httpMock.expectOne('/api/v1/integrations/1/enable');
expect(req.request.method).toBe('POST');
req.flush({ ...mockIntegration, status: IntegrationStatus.Active });
tick();
expect(component.integration?.status).toBe(IntegrationStatus.Active);
}));
it('should disable integration', fakeAsync(() => {
component.integration = mockIntegration;
fixture.detectChanges();
component.disableIntegration();
tick();
const req = httpMock.expectOne('/api/v1/integrations/1/disable');
expect(req.request.method).toBe('POST');
req.flush({ ...mockIntegration, status: IntegrationStatus.Disabled });
tick();
expect(component.integration?.status).toBe(IntegrationStatus.Disabled);
}));
it('should display configuration fields', () => {
component.integration = mockIntegration;
fixture.detectChanges();
const configKeys = component.getConfigurationKeys();
expect(configKeys).toContain('endpoint');
expect(configKeys).toContain('username');
expect(component.lastTestResult?.success).toBeFalse();
expect(component.lastTestResult?.message).toContain('ENOTFOUND');
expect(component.lastTestResult?.duration).toBe('00:00:00.1000000');
});
it('should mask sensitive configuration values', () => {
component.integration = {
...mockIntegration,
configuration: {
endpoint: 'https://harbor.example.com',
password: 'authref://vault/harbor#password'
}
};
fixture.detectChanges();
it('captures health results using checkedAt and message fields', () => {
component.checkHealth();
expect(component.getDisplayValue('password', 'authref://vault/harbor#password')).toBe('••••••••');
expect(component.getDisplayValue('endpoint', 'https://harbor.example.com')).toBe('https://harbor.example.com');
});
it('should calculate health status correctly', () => {
component.integration = mockIntegration;
expect(component.getHealthStatusClass()).toBe('status-healthy');
component.integration = { ...mockIntegration, healthStatus: 'unhealthy' };
expect(component.getHealthStatusClass()).toBe('status-unhealthy');
component.integration = { ...mockIntegration, healthStatus: 'unknown' };
expect(component.getHealthStatusClass()).toBe('status-unknown');
});
it('should format last health check time', () => {
component.integration = mockIntegration;
const formatted = component.formatLastHealthCheck();
expect(formatted).toBeTruthy();
expect(typeof formatted).toBe('string');
});
it('should emit close event', () => {
const closeSpy = spyOn(component.closed, 'emit');
component.close();
expect(closeSpy).toHaveBeenCalled();
expect(component.lastHealthResult?.status).toBe(HealthStatus.Unhealthy);
expect(component.lastHealthResult?.message).toContain('ENOTFOUND');
expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z');
});
});

View File

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

View File

@@ -201,6 +201,9 @@ export class IntegrationHubComponent {
}
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 { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { IntegrationListComponent } from './integration-list.component';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
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', () => {
let component: IntegrationListComponent;
let fixture: ComponentFixture<IntegrationListComponent>;
let httpMock: HttpTestingController;
const mockIntegrations: 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' },
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'
}
];
let component: IntegrationListComponent;
let integrationService: jasmine.SpyObj<IntegrationService>;
let router: Router;
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({
imports: [IntegrationListComponent],
providers: [
IntegrationService,
provideHttpClient(),
provideHttpClientTesting()
]
}).compileComponents();
provideRouter([]),
{ provide: IntegrationService, useValue: integrationService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { type: 'Registry' },
},
},
},
],
})
.overrideComponent(IntegrationListComponent, {
remove: { imports: [DoctorChecksInlineComponent] },
add: { imports: [DoctorChecksInlineStubComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(IntegrationListComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load integrations on init', () => {
router = TestBed.inject(Router);
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', () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
component.filterByType(IntegrationType.Registry);
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].type).toBe(IntegrationType.Registry);
it('loads canonical list responses and renders health from lastHealthStatus', () => {
expect(component.integrations.length).toBe(1);
expect(component.integrations[0].id).toBe('int-1');
expect(component.getHealthLabel(component.integrations[0].lastHealthStatus)).toBe('Healthy');
});
it('should filter integrations by status', () => {
fixture.detectChanges();
it('passes backend status filters through the list query', () => {
component.filterStatus = IntegrationStatus.Disabled;
component.loadIntegrations();
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
component.filterByStatus(IntegrationStatus.Error);
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].status).toBe(IntegrationStatus.Error);
expect(integrationService.list).toHaveBeenCalledWith(jasmine.objectContaining({
type: IntegrationType.Registry,
status: IntegrationStatus.Disabled,
}));
});
it('should filter integrations by search text', () => {
fixture.detectChanges();
it('preserves the current scope query when opening typed onboarding', () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
component.addIntegration();
component.searchText = 'Harbor';
component.applyFilters();
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');
expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'onboarding', 'registry'], {
queryParamsHandling: 'merge',
});
});
});

View File

@@ -7,9 +7,12 @@ import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { integrationWorkspaceCommands } from './integration-route-context';
import {
HealthStatus,
Integration,
IntegrationType,
IntegrationStatus,
getHealthStatusColor,
getHealthStatusLabel,
getIntegrationStatusLabel,
getIntegrationStatusColor,
getProviderLabel,
@@ -32,11 +35,11 @@ import {
<section class="filters">
<select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select">
<option [ngValue]="undefined">All Statuses</option>
<option [ngValue]="IntegrationStatus.Pending">Pending</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.Disabled">Disabled</option>
<option [ngValue]="IntegrationStatus.Archived">Archived</option>
</select>
<input
type="text"
@@ -77,10 +80,10 @@ import {
</tr>
</thead>
<tbody>
@for (integration of integrations; track integration.integrationId) {
@for (integration of integrations; track integration.id) {
<tr>
<td>
<a [routerLink]="integrationDetailRoute(integration.integrationId)">{{ integration.name }}</a>
<a [routerLink]="integrationDetailRoute(integration.id)">{{ integration.name }}</a>
</td>
<td>{{ getProviderName(integration.provider) }}</td>
<td>
@@ -89,16 +92,16 @@ import {
</span>
</td>
<td>
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')">
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }}
<span [class]="'health-badge health-' + getHealthColor(integration.lastHealthStatus)">
{{ getHealthLabel(integration.lastHealthStatus) }}
</span>
</td>
<td>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'short') : 'Never' }}</td>
<td>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'short') : 'Never' }}</td>
<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)="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>
<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>
</tr>
}
@@ -194,7 +197,7 @@ import {
color: var(--color-status-error-text);
}
.status-disabled, .health-degraded {
.status-disabled, .status-archived, .health-degraded {
background: var(--color-border-primary);
color: var(--color-text-primary);
}
@@ -355,9 +358,9 @@ export class IntegrationListComponent implements OnInit {
}
testConnection(integration: Integration): void {
this.integrationService.testConnection(integration.integrationId).subscribe({
this.integrationService.testConnection(integration.id).subscribe({
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();
},
error: (err) => {
@@ -367,9 +370,9 @@ export class IntegrationListComponent implements OnInit {
}
checkHealth(integration: Integration): void {
this.integrationService.getHealth(integration.integrationId).subscribe({
this.integrationService.getHealth(integration.id).subscribe({
next: (result) => {
alert(`Health: ${getIntegrationStatusLabel(result.status)} - ${result.lastTestSuccess ? 'OK' : 'Issues detected'}`);
alert(`Health: ${getHealthStatusLabel(result.status)} - ${result.message || 'No detail returned.'}`);
this.loadIntegrations();
},
error: (err) => {
@@ -387,6 +390,14 @@ export class IntegrationListComponent implements OnInit {
return getIntegrationStatusColor(status);
}
getHealthLabel(status: HealthStatus): string {
return getHealthStatusLabel(status);
}
getHealthColor(status: HealthStatus): string {
return getHealthStatusColor(status);
}
getProviderName(provider: number): string {
return getProviderLabel(provider);
}
@@ -406,7 +417,7 @@ export class IntegrationListComponent implements OnInit {
}
editIntegration(integration: Integration): void {
void this.router.navigate(this.integrationCommands(integration.integrationId), {
void this.router.navigate(this.integrationCommands(integration.id), {
queryParams: { edit: true },
queryParamsHandling: 'merge',
});
@@ -417,7 +428,9 @@ export class IntegrationListComponent implements OnInit {
? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
: this.integrationCommands('onboarding');
void this.router.navigate(commands);
void this.router.navigate(commands, {
queryParamsHandling: 'merge',
});
}
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 {
Registry = 1,
Scm = 2,
@@ -14,85 +9,85 @@ export enum IntegrationType {
Marketplace = 8,
}
export enum IntegrationStatus {
Draft = 0,
PendingVerification = 1,
Active = 2,
Degraded = 3,
Paused = 4,
Failed = 5,
}
export enum IntegrationProvider {
// Registry providers
DockerHub = 100,
Harbor = 101,
Ecr = 102,
Harbor = 100,
Ecr = 101,
Gcr = 102,
Acr = 103,
Gcr = 104,
Ghcr = 105,
Quay = 106,
JfrogArtifactory = 107,
// SCM providers
GitHub = 200,
GitLab = 201,
Gitea = 202,
Bitbucket = 203,
DockerHub = 104,
Quay = 105,
Artifactory = 106,
Nexus = 107,
GitHubContainerRegistry = 108,
GitLabContainerRegistry = 109,
GitHubApp = 200,
GitLabServer = 201,
Bitbucket = 202,
Gitea = 203,
AzureDevOps = 204,
// CI providers
GitHubActions = 300,
GitLabCi = 301,
GiteaActions = 302,
Jenkins = 303,
CircleCi = 304,
AzurePipelines = 305,
Jenkins = 302,
CircleCi = 303,
AzurePipelines = 304,
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
ZastavaEbpf = 400,
ZastavaEtw = 401,
ZastavaDyld = 402,
export enum IntegrationStatus {
Pending = 0,
Active = 1,
Failed = 2,
Disabled = 3,
Archived = 4,
}
// Feed providers
Concelier = 500,
Excititor = 501,
// Artifact providers
SbomUpload = 600,
VexUpload = 601,
export enum HealthStatus {
Unknown = 0,
Healthy = 1,
Degraded = 2,
Unhealthy = 3,
}
export interface Integration {
integrationId: string;
tenantId: string;
id: string;
name: string;
description?: string;
description?: string | null;
type: IntegrationType;
provider: IntegrationProvider;
status: IntegrationStatus;
baseUrl?: string;
authRef?: string;
configuration?: Record<string, unknown>;
environment?: string;
tags?: string;
ownerId?: string;
endpoint: string;
hasAuth: boolean;
organizationId?: string | null;
lastHealthStatus: HealthStatus;
lastHealthCheckAt?: string | null;
createdAt: string;
createdBy: string;
modifiedAt?: string;
modifiedBy?: string;
lastTestedAt?: string;
lastTestSuccess?: boolean;
lastTestError?: string;
lastSyncAt?: string;
lastEventAt?: string;
paused: boolean;
pauseReason?: string;
pauseTicket?: string;
pausedAt?: string;
pausedBy?: string;
consecutiveFailures: number;
version: number;
updatedAt: string;
createdBy?: string | null;
updatedBy?: string | null;
tags: string[];
}
export interface IntegrationListResponse {
@@ -100,59 +95,56 @@ export interface IntegrationListResponse {
totalCount: number;
page: number;
pageSize: number;
hasMore: boolean;
totalPages: number;
}
export interface CreateIntegrationRequest {
name: string;
description?: string;
description?: string | null;
type: IntegrationType;
provider: IntegrationProvider;
baseUrl?: string;
authRef?: string;
configuration?: Record<string, unknown>;
environment?: string;
tags?: string;
ownerId?: string;
endpoint: string;
authRefUri?: string | null;
organizationId?: string | null;
extendedConfig?: Record<string, unknown> | null;
tags?: string[] | null;
}
export interface UpdateIntegrationRequest {
name?: string;
description?: string;
baseUrl?: string;
authRef?: string;
configuration?: Record<string, unknown>;
environment?: string;
tags?: string;
ownerId?: string;
}
export interface PauseIntegrationRequest {
reason: string;
ticket?: string;
name?: string | null;
description?: string | null;
endpoint?: string | null;
authRefUri?: string | null;
organizationId?: string | null;
extendedConfig?: Record<string, unknown> | null;
tags?: string[] | null;
status?: IntegrationStatus | null;
}
export interface TestConnectionResponse {
integrationId: string;
success: boolean;
errorMessage?: string;
message?: string | null;
details?: Record<string, string> | null;
duration: string;
testedAt: string;
latencyMs?: number;
details?: Record<string, unknown>;
}
export interface IntegrationHealthResponse {
integrationId: string;
status: IntegrationStatus;
lastTestedAt?: string;
lastTestSuccess?: boolean;
lastSyncAt?: string;
lastEventAt?: string;
consecutiveFailures: number;
uptimePercentage?: number;
averageLatencyMs?: number;
status: HealthStatus;
message?: string | null;
details?: Record<string, string> | null;
checkedAt: string;
duration: string;
}
export interface SupportedProviderInfo {
name: string;
type: IntegrationType;
provider: IntegrationProvider;
}
// Display helpers
export function getIntegrationTypeLabel(type: IntegrationType): string {
switch (type) {
case IntegrationType.Registry:
@@ -162,7 +154,7 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
case IntegrationType.CiCd:
return 'CI/CD';
case IntegrationType.RepoSource:
return 'Repo Source';
return 'Repository Source';
case IntegrationType.RuntimeHost:
return 'Runtime Host';
case IntegrationType.FeedMirror:
@@ -178,18 +170,16 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
export function getIntegrationStatusLabel(status: IntegrationStatus): string {
switch (status) {
case IntegrationStatus.Draft:
return 'Draft';
case IntegrationStatus.PendingVerification:
case IntegrationStatus.Pending:
return 'Pending';
case IntegrationStatus.Active:
return 'Active';
case IntegrationStatus.Degraded:
return 'Degraded';
case IntegrationStatus.Paused:
return 'Paused';
case IntegrationStatus.Failed:
return 'Failed';
case IntegrationStatus.Disabled:
return 'Disabled';
case IntegrationStatus.Archived:
return 'Archived';
default:
return 'Unknown';
}
@@ -197,76 +187,139 @@ export function getIntegrationStatusLabel(status: IntegrationStatus): string {
export function getIntegrationStatusColor(status: IntegrationStatus): string {
switch (status) {
case IntegrationStatus.Pending:
return 'pending';
case IntegrationStatus.Active:
return 'success';
case IntegrationStatus.Draft:
case IntegrationStatus.PendingVerification:
return 'info';
case IntegrationStatus.Degraded:
return 'warning';
case IntegrationStatus.Paused:
return 'secondary';
return 'active';
case IntegrationStatus.Failed:
return 'danger';
return 'failed';
case IntegrationStatus.Disabled:
return 'disabled';
case IntegrationStatus.Archived:
return 'archived';
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 {
switch (provider) {
case IntegrationProvider.DockerHub:
return 'Docker Hub';
case IntegrationProvider.Harbor:
return 'Harbor';
case IntegrationProvider.Ecr:
return 'AWS ECR';
case IntegrationProvider.Acr:
return 'Azure ACR';
case IntegrationProvider.Gcr:
return 'Google GCR';
case IntegrationProvider.Ghcr:
return 'GitHub GHCR';
case IntegrationProvider.Acr:
return 'Azure ACR';
case IntegrationProvider.DockerHub:
return 'Docker Hub';
case IntegrationProvider.Quay:
return 'Quay.io';
case IntegrationProvider.JfrogArtifactory:
return 'JFrog Artifactory';
case IntegrationProvider.GitHub:
return 'GitHub';
case IntegrationProvider.GitLab:
return 'GitLab';
case IntegrationProvider.Gitea:
return 'Gitea';
return 'Quay';
case IntegrationProvider.Artifactory:
return 'Artifactory';
case IntegrationProvider.Nexus:
return 'Nexus';
case IntegrationProvider.GitHubContainerRegistry:
return 'GitHub Container Registry';
case IntegrationProvider.GitLabContainerRegistry:
return 'GitLab Container Registry';
case IntegrationProvider.GitHubApp:
return 'GitHub App';
case IntegrationProvider.GitLabServer:
return 'GitLab Server';
case IntegrationProvider.Bitbucket:
return 'Bitbucket';
case IntegrationProvider.Gitea:
return 'Gitea';
case IntegrationProvider.AzureDevOps:
return 'Azure DevOps';
case IntegrationProvider.GitHubActions:
return 'GitHub Actions';
case IntegrationProvider.GitLabCi:
return 'GitLab CI';
case IntegrationProvider.GiteaActions:
return 'Gitea Actions';
case IntegrationProvider.Jenkins:
return 'Jenkins';
case IntegrationProvider.CircleCi:
return 'CircleCI';
case IntegrationProvider.AzurePipelines:
return 'Azure Pipelines';
case IntegrationProvider.ZastavaEbpf:
return 'Zastava (eBPF)';
case IntegrationProvider.ZastavaEtw:
return 'Zastava (ETW)';
case IntegrationProvider.ZastavaDyld:
return 'Zastava (dyld)';
case IntegrationProvider.Concelier:
return 'Concelier';
case IntegrationProvider.Excititor:
return 'Excititor';
case IntegrationProvider.SbomUpload:
return 'SBOM Upload';
case IntegrationProvider.VexUpload:
return 'VEX Upload';
case IntegrationProvider.ArgoWorkflows:
return 'Argo Workflows';
case IntegrationProvider.Tekton:
return 'Tekton';
case IntegrationProvider.NpmRegistry:
return 'npm Registry';
case IntegrationProvider.PyPi:
return 'PyPI';
case IntegrationProvider.MavenCentral:
return 'Maven Central';
case IntegrationProvider.NuGetOrg:
return 'NuGet.org';
case IntegrationProvider.CratesIo:
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:
return 'Unknown';
}

View File

@@ -1,34 +1,26 @@
import { TestBed } from '@angular/core/testing';
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 { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models';
import {
HealthStatus,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
} from './integration.models';
describe('IntegrationService', () => {
let service: IntegrationService;
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(() => {
TestBed.configureTestingModule({
providers: [
IntegrationService,
provideHttpClient(),
provideHttpClientTesting()
]
provideHttpClientTesting(),
],
});
service = TestBed.inject(IntegrationService);
@@ -39,155 +31,84 @@ describe('IntegrationService', () => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getIntegrations', () => {
it('should fetch all integrations', () => {
const mockIntegrations = [mockIntegration];
service.getIntegrations().subscribe(integrations => {
expect(integrations.length).toBe(1);
expect(integrations[0].name).toBe('Harbor Registry');
});
const req = httpMock.expectOne('/api/v1/integrations');
expect(req.request.method).toBe('GET');
req.flush(mockIntegrations);
});
it('should filter by type', () => {
service.getIntegrations(IntegrationType.Registry).subscribe();
const req = httpMock.expectOne('/api/v1/integrations?type=ContainerRegistry');
expect(req.request.method).toBe('GET');
req.flush([]);
});
it('should filter by status', () => {
service.getIntegrations(undefined, IntegrationStatus.Active).subscribe();
const req = httpMock.expectOne('/api/v1/integrations?status=Active');
expect(req.request.method).toBe('GET');
req.flush([]);
});
});
describe('getIntegration', () => {
it('should fetch a single integration by id', () => {
service.getIntegration('1').subscribe(integration => {
expect(integration.id).toBe('1');
});
const req = httpMock.expectOne('/api/v1/integrations/1');
expect(req.request.method).toBe('GET');
req.flush(mockIntegration);
});
});
describe('createIntegration', () => {
it('should create a new integration', () => {
const createRequest = {
name: 'New Registry',
it('lists integrations with canonical query parameters', () => {
service.list({
type: IntegrationType.Registry,
provider: 'harbor',
configuration: { endpoint: 'https://new.example.com' }
};
status: IntegrationStatus.Active,
provider: IntegrationProvider.Harbor,
search: 'harbor',
page: 2,
pageSize: 10,
}).subscribe();
service.createIntegration(createRequest).subscribe(integration => {
expect(integration.name).toBe('New Registry');
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 });
});
it('creates integrations against the canonical API contract', () => {
const request = {
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.create(request).subscribe((integration) => {
expect(integration.id).toBe('int-1');
expect(integration.endpoint).toBe('https://harbor.example.com');
expect(integration.hasAuth).toBeTrue();
});
const req = httpMock.expectOne('/api/v1/integrations');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(createRequest);
req.flush({ ...mockIntegration, ...createRequest });
expect(req.request.body).toEqual(request);
req.flush({
id: 'int-1',
name: 'Production Harbor',
description: null,
type: IntegrationType.Registry,
provider: IntegrationProvider.Harbor,
status: IntegrationStatus.Pending,
endpoint: 'https://harbor.example.com',
hasAuth: true,
organizationId: 'platform',
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('updateIntegration', () => {
it('should update an existing integration', () => {
const updateRequest = { name: 'Updated Name' };
service.updateIntegration('1', updateRequest).subscribe(integration => {
expect(integration.name).toBe('Updated Name');
it('retrieves the supported provider catalog from the canonical endpoint', () => {
service.getSupportedProviders().subscribe((providers) => {
expect(providers).toEqual([
{ 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('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');
const req = httpMock.expectOne('/api/v1/integrations/providers');
expect(req.request.method).toBe('GET');
req.flush(mockLogs);
});
req.flush([
{ name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
]);
});
});

View File

@@ -3,21 +3,18 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
Integration,
IntegrationListResponse,
CreateIntegrationRequest,
UpdateIntegrationRequest,
PauseIntegrationRequest,
TestConnectionResponse,
Integration,
IntegrationHealthResponse,
IntegrationType,
IntegrationListResponse,
IntegrationProvider,
IntegrationStatus,
IntegrationType,
SupportedProviderInfo,
TestConnectionResponse,
UpdateIntegrationRequest,
} from './integration.models';
/**
* Service for interacting with the Integration Catalog API.
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
*/
@Injectable({
providedIn: 'root',
})
@@ -25,13 +22,10 @@ export class IntegrationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/v1/integrations`;
/**
* List integrations with filtering and pagination.
*/
list(params: {
type?: IntegrationType;
provider?: IntegrationProvider;
status?: IntegrationStatus;
environment?: string;
search?: string;
page?: number;
pageSize?: number;
@@ -41,12 +35,12 @@ export class IntegrationService {
if (params.type !== undefined) {
httpParams = httpParams.set('type', params.type.toString());
}
if (params.provider !== undefined) {
httpParams = httpParams.set('provider', params.provider.toString());
}
if (params.status !== undefined) {
httpParams = httpParams.set('status', params.status.toString());
}
if (params.environment) {
httpParams = httpParams.set('environment', params.environment);
}
if (params.search) {
httpParams = httpParams.set('search', params.search);
}
@@ -60,66 +54,31 @@ export class IntegrationService {
return this.http.get<IntegrationListResponse>(this.baseUrl, { params: httpParams });
}
/**
* Get an integration by ID.
*/
get(integrationId: string): Observable<Integration> {
return this.http.get<Integration>(`${this.baseUrl}/${integrationId}`);
}
/**
* Create a new integration.
*/
create(request: CreateIntegrationRequest): Observable<Integration> {
return this.http.post<Integration>(this.baseUrl, request);
}
/**
* Update an existing integration.
*/
update(integrationId: string, request: UpdateIntegrationRequest): Observable<Integration> {
return this.http.put<Integration>(`${this.baseUrl}/${integrationId}`, request);
}
/**
* Delete an integration.
*/
delete(integrationId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${integrationId}`);
}
/**
* Test connection to an integration.
*/
testConnection(integrationId: string): Observable<TestConnectionResponse> {
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> {
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">
<!-- Stepper Header -->
<div class="wizard-stepper">
@for (step of steps; track step; let i = $index) {
<button
@@ -17,20 +15,38 @@
}
</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">
<!-- Provider Step -->
@if (currentStep() === 'provider') {
<div class="step-content">
<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>
@if (supportedProviders().length === 0) {
<div class="check-item status-warning">
<span class="check-status">!!</span>
<div class="check-info">
<span class="check-name">No supported providers</span>
<span class="check-message">This category has no installed connector plugins yet.</span>
</div>
</div>
} @else {
<div class="provider-grid">
@for (provider of providers(); track provider.id) {
@for (provider of supportedProviders(); track provider.provider) {
<button
class="provider-card"
[class.selected]="draft().provider === provider.id"
(click)="selectProvider(provider.id)"
[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>
@@ -38,51 +54,78 @@
</button>
}
</div>
}
</div>
}
<!-- Auth Step -->
@if (currentStep() === 'auth') {
<div class="step-content">
<h2>Configure Authentication</h2>
<p class="step-description">Set up authentication for {{ selectedProvider()?.name }}.</p>
<h2>Connection & Credentials</h2>
<p class="step-description">StellaOps stores only AuthRef URIs here. Keep the actual secret in your vault.</p>
<div class="auth-methods">
@for (method of authMethods(); track method.id) {
<div
class="auth-method-card"
[class.selected]="draft().authMethod === method.id"
(click)="selectAuthMethod(method.id)"
>
<div class="auth-method-header">
<input
type="radio"
[checked]="draft().authMethod === method.id"
[id]="'auth-' + method.id"
/>
<label [for]="'auth-' + method.id">{{ method.name }}</label>
</div>
<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) {
@if (selectedProvider(); as provider) {
<div class="auth-method-card selected">
<div class="form-field">
<label [for]="'field-' + field.id">
{{ field.name }}
<label for="endpoint">
Endpoint
<span class="required">*</span>
</label>
<input
id="endpoint"
type="text"
[value]="draft().endpoint"
[placeholder]="provider.defaultEndpoint"
(input)="updateEndpoint($any($event.target).value)"
/>
<span class="field-hint">{{ provider.endpointHint }}</span>
</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>
@if (field.type === 'text' || field.type === 'password') {
<input
[type]="field.type"
[id]="'field-' + field.id"
[id]="'config-' + field.id"
type="text"
[value]="draft().extendedConfig[field.id] || ''"
[placeholder]="field.placeholder || ''"
[value]="draft().authValues[field.id] || ''"
(input)="updateAuthValue(field.id, $any($event.target).value)"
(input)="updateConfigField(field.id, $any($event.target).value)"
/>
}
@if (field.hint) {
<span class="field-hint">{{ field.hint }}</span>
}
@@ -92,15 +135,11 @@
}
</div>
}
</div>
</div>
}
<!-- Scope Step -->
@if (currentStep() === 'scope') {
<div class="step-content">
<h2>Define Scope</h2>
<p class="step-description">Specify which resources to include in this integration.</p>
<h2>Discovery Scope</h2>
<p class="step-description">Define which repositories, namespaces, or tag patterns StellaOps should use for this connector.</p>
<div class="scope-form">
@if (integrationType() === 'registry' || integrationType() === 'scm') {
@@ -108,11 +147,10 @@
<label for="repositories">Repositories</label>
<textarea
id="repositories"
placeholder="One repository per line&#10;e.g., owner/repo"
rows="4"
(input)="parseScopeInput('repositories', $any($event.target).value)"
>{{ (draft().scope.repositories || []).join('\n') }}</textarea>
<span class="field-hint">Leave empty to include all accessible repositories.</span>
placeholder="One repository per line&#10;example/api&#10;platform/web"
(input)="parseListInput('repositories', $any($event.target).value)"
>{{ draft().repositories.join('\n') }}</textarea>
</div>
}
@@ -121,45 +159,44 @@
<label for="branches">Branch Patterns</label>
<textarea
id="branches"
placeholder="Branch patterns (glob supported)&#10;e.g., main, release/*"
rows="3"
(input)="parseScopeInput('branches', $any($event.target).value)"
>{{ (draft().scope.branches || []).join('\n') }}</textarea>
placeholder="main&#10;release/*"
(input)="parseListInput('branches', $any($event.target).value)"
>{{ draft().branches.join('\n') }}</textarea>
</div>
}
@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">
<label for="tagPatterns">Tag Patterns</label>
<textarea
id="tagPatterns"
placeholder="Tag patterns (glob supported)&#10;e.g., v*, latest, release-*"
rows="3"
(input)="parseScopeInput('tagPatterns', $any($event.target).value)"
>{{ (draft().scope.tagPatterns || []).join('\n') }}</textarea>
placeholder="latest&#10;release-*"
(input)="parseListInput('tagPatterns', $any($event.target).value)"
>{{ draft().tagPatterns.join('\n') }}</textarea>
</div>
}
@if (integrationType() === 'host') {
<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>
}
<span class="field-hint">At least one owner, repository, namespace, branch, or tag scope is required before creation.</span>
</div>
</div>
}
<!-- Schedule Step -->
@if (currentStep() === 'schedule') {
<div class="step-content">
<h2>Configure Schedule</h2>
<p class="step-description">Set up how often scans should run.</p>
<h2>Check Schedule</h2>
<p class="step-description">Define how StellaOps should revisit this connector after onboarding.</p>
<div class="schedule-options">
@for (option of scheduleOptions; track option.value) {
@@ -168,12 +205,8 @@
[class.selected]="draft().schedule.type === option.value"
(click)="updateSchedule('type', $any(option.value))"
>
<input
type="radio"
[checked]="draft().schedule.type === option.value"
[id]="'schedule-' + option.value"
/>
<label [for]="'schedule-' + option.value">
<input type="radio" [checked]="draft().schedule.type === option.value" />
<label>
<span class="schedule-label">{{ option.label }}</span>
<span class="schedule-desc">{{ option.description }}</span>
</label>
@@ -183,14 +216,14 @@
@if (draft().schedule.type === 'interval') {
<div class="form-field">
<label for="interval">Scan Interval</label>
<label for="interval">Check Interval</label>
<select
id="interval"
[value]="draft().schedule.intervalMinutes || 60"
(change)="updateSchedule('intervalMinutes', +$any($event.target).value)"
>
@for (opt of intervalOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
@for (option of intervalOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
</div>
@@ -198,55 +231,32 @@
@if (draft().schedule.type === 'cron') {
<div class="form-field">
<label for="cron">Cron Expression</label>
<label for="cronExpression">Cron Expression</label>
<input
id="cronExpression"
type="text"
id="cron"
placeholder="0 0 * * *"
[value]="draft().schedule.cronExpression || ''"
placeholder="0 */6 * * *"
(input)="updateSchedule('cronExpression', $any($event.target).value)"
/>
<span class="field-hint">Standard cron syntax (minute hour day month weekday)</span>
</div>
}
@if (integrationType() === 'registry' || integrationType() === 'scm') {
<div class="webhook-toggle">
<label class="toggle-label">
<input
type="checkbox"
[checked]="draft().webhookEnabled"
(change)="toggleWebhook()"
/>
<span>Enable webhook for real-time triggers</span>
<input type="checkbox" [checked]="draft().webhookEnabled" (change)="toggleWebhook()" />
<span>Record webhook intent in connector metadata</span>
</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>
}
<!-- Preflight Step -->
@if (currentStep() === 'preflight') {
<div class="step-content">
<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">
@for (check of preflightChecks(); track check.id) {
@@ -272,87 +282,48 @@
</div>
@if (!preflightRunning() && preflightChecks().length > 0) {
<button
type="button"
class="btn btn-secondary"
(click)="runPreflightChecks()"
>
Re-run Checks
</button>
}
@if (preflightRunning()) {
<p class="running-message">Running preflight checks...</p>
<button type="button" class="btn btn-secondary" (click)="runPreflightChecks()">Re-run Checks</button>
}
</div>
}
<!-- Review Step -->
@if (currentStep() === 'review') {
<div class="step-content">
<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">
<label for="name">Integration Name</label>
<label for="integrationName">
Integration Name
<span class="required">*</span>
</label>
<input
id="integrationName"
type="text"
id="name"
[value]="draft().name"
(input)="updateName($any($event.target).value)"
placeholder="Enter a name for this integration"
placeholder="Production Harbor Registry"
/>
</div>
@if (selectedProvider(); as provider) {
<div class="review-summary">
<div class="summary-section">
<h3>Provider</h3>
<p>{{ selectedProvider()?.name }} ({{ selectedProvider()?.type }})</p>
<p>{{ provider.name }}</p>
</div>
<div class="summary-section">
<h3>Authentication</h3>
<p>{{ selectedAuthMethod()?.name }}</p>
<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 }}
@if (draft().schedule.type === 'interval') {
- Every {{ draft().schedule.intervalMinutes }} minutes
}
@if (draft().schedule.type === 'cron') {
- {{ draft().schedule.cronExpression }}
}
</p>
<p>{{ draft().schedule.type | titlecase }}</p>
</div>
@if (draft().webhookEnabled) {
<div class="summary-section">
<h3>Webhook</h3>
<p>Enabled</p>
</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>
}
@@ -381,7 +352,6 @@
}
</div>
<!-- Wizard Footer -->
<div class="wizard-footer">
<button type="button" class="btn btn-text" (click)="onCancel()">Cancel</button>
@@ -391,22 +361,10 @@
}
@if (currentStep() !== 'review') {
<button
type="button"
class="btn btn-primary"
[disabled]="!canGoNext()"
(click)="goNext()"
>
Next
</button>
<button type="button" class="btn btn-primary" [disabled]="!canGoNext()" (click)="goNext()">Next</button>
} @else {
<button
type="button"
class="btn btn-primary"
[disabled]="!canGoNext()"
(click)="onSubmit()"
>
Create Integration
<button type="button" class="btn btn-primary" [disabled]="!canGoNext() || creating()" (click)="onSubmit()">
{{ creating() ? 'Creating...' : 'Create Integration' }}
</button>
}
</div>

View File

@@ -1,264 +1,76 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { signal } from '@angular/core';
import { IntegrationWizardComponent } from './integration-wizard.component';
import {
IntegrationType,
IntegrationProvider,
WizardStep,
REGISTRY_PROVIDERS,
SCM_PROVIDERS,
CI_PROVIDERS,
HOST_PROVIDERS,
} from './models/integration.models';
import { TestBed } from '@angular/core/testing';
import { IntegrationProvider, IntegrationType } from '../integration-hub/integration.models';
import { IntegrationWizardComponent } from './integration-wizard.component';
import { resolveSupportedProviders } from './models/integration.models';
/**
* Unit tests for Integration Wizard Component
* @sprint SPRINT_20251229_014_FE_integration_wizards
*/
describe('IntegrationWizardComponent', () => {
let component: IntegrationWizardComponent;
let fixture: ComponentFixture<IntegrationWizardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IntegrationWizardComponent, CommonModule, FormsModule],
}).compileComponents();
});
function createComponent(integrationType: IntegrationType = 'registry'): void {
fixture = TestBed.createComponent(IntegrationWizardComponent);
component = fixture.componentInstance;
// Use ComponentRef to set required inputs
fixture.componentRef.setInput('integrationType', integrationType);
fixture.detectChanges();
function createComponent() {
component = TestBed.runInInjectionContext(() => new IntegrationWizardComponent());
(component as any).integrationType = () => 'scm';
(component as any).supportedProviders = () => resolveSupportedProviders('scm', [
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
]);
component.draft.update((draft) => ({ ...draft, type: 'scm' }));
}
describe('Initialization', () => {
it('should create the component', () => {
it('selects a supported provider and seeds its default endpoint', () => {
createComponent();
expect(component).toBeTruthy();
component.selectProvider(IntegrationProvider.GitHubApp);
expect(component.draft().provider).toBe(IntegrationProvider.GitHubApp);
expect(component.draft().endpoint).toBe('https://github.com');
expect(component.draft().name).toContain('GitHub App');
});
it('should initialize with provider step', () => {
it('requires AuthRef URI and provider metadata before leaving the connection step', () => {
createComponent();
expect(component.currentStep()).toBe('provider');
component.selectProvider(IntegrationProvider.GitHubApp);
component.currentStep.set('auth');
expect(component.canGoNext()).toBeFalse();
component.updateAuthRefUri('authref://vault/github#app');
component.updateConfigField('appId', '12345');
component.updateConfigField('installationId', '67890');
expect(component.canGoNext()).toBeTrue();
});
it('should have 6 wizard steps', () => {
it('emits a canonical create request instead of a UI-only draft', () => {
createComponent();
expect(component.steps).toEqual(['provider', 'auth', 'scope', 'schedule', 'preflight', 'review']);
});
component.selectProvider(IntegrationProvider.GitHubApp);
const emitSpy = jasmine.createSpy('emit');
spyOn(component.create, 'emit').and.callFake(emitSpy);
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('should show registry providers for registry type', () => {
createComponent('registry');
expect(component.providers()).toEqual(REGISTRY_PROVIDERS);
});
it('should show SCM providers for scm type', () => {
createComponent('scm');
expect(component.providers()).toEqual(SCM_PROVIDERS);
});
it('should show CI providers for ci type', () => {
createComponent('ci');
expect(component.providers()).toEqual(CI_PROVIDERS);
});
it('should show host providers for host type', () => {
createComponent('host');
expect(component.providers()).toEqual(HOST_PROVIDERS);
});
});
describe('Step Navigation', () => {
it('should compute current step index correctly', () => {
createComponent();
expect(component.currentStepIndex()).toBe(0);
});
it('should prevent navigation when canGoNext is false', () => {
createComponent();
// Without a provider selected, canGoNext should be false
expect(component.canGoNext()).toBe(false);
});
it('should allow navigation after provider selection', () => {
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', () => {
it('should update draft when provider is selected', () => {
createComponent('registry');
const provider = REGISTRY_PROVIDERS[0];
component.selectProvider(provider.id);
expect(component.draft().provider).toBe(provider.id);
});
it('should return selected provider info', () => {
createComponent('registry');
const provider = REGISTRY_PROVIDERS[0];
component.selectProvider(provider.id);
expect(component.selectedProvider()?.id).toBe(provider.id);
});
});
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.updateAuthRefUri('authref://vault/github#app');
component.updateConfigField('appId', '12345');
component.updateConfigField('installationId', '67890');
component.updateOrganizationId('platform');
component.parseListInput('repositories', 'platform/api');
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.updateName('GitHub App Platform');
component.currentStep.set('review');
component.onSubmit();
expect(createSpy).toHaveBeenCalledWith(component.draft());
});
expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
name: 'GitHub App Platform',
type: IntegrationType.Scm,
provider: IntegrationProvider.GitHubApp,
endpoint: 'https://github.com',
authRefUri: 'authref://vault/github#app',
organizationId: 'platform',
extendedConfig: jasmine.objectContaining({
appId: '12345',
installationId: '67890',
repositories: ['platform/api'],
scheduleType: 'interval',
intervalMinutes: 60,
}),
}));
});
});

View File

@@ -3,30 +3,26 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CreateIntegrationRequest } from '../integration-hub/integration.models';
import {
buildCreateIntegrationRequest,
IntegrationDraft,
IntegrationProvider,
IntegrationProviderInfo,
IntegrationType,
WizardStep,
IntegrationOnboardingType,
IntegrationProviderDefinition,
intervalOptions,
PreflightCheck,
AuthMethod,
REGISTRY_PROVIDERS,
SCM_PROVIDERS,
CI_PROVIDERS,
HOST_PROVIDERS,
AUTH_METHODS,
resolveProviderDefinition,
scheduleOptions,
WizardStep,
} from './models/integration.models';
/**
* Integration onboarding wizard component (Sprint: SPRINT_20251229_014)
* Provides guided setup for registry, SCM, CI, and host integrations.
*/
@Component({
selector: 'app-integration-wizard',
standalone: true,
@@ -36,74 +32,55 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IntegrationWizardComponent {
/** Integration type to create */
readonly integrationType = input.required<IntegrationType>();
readonly integrationType = input.required<IntegrationOnboardingType>();
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>();
/** Emits when integration is created */
readonly create = output<IntegrationDraft>();
readonly create = output<CreateIntegrationRequest>();
readonly steps: WizardStep[] = ['provider', 'auth', 'scope', 'schedule', 'preflight', 'review'];
readonly currentStep = signal<WizardStep>('provider');
readonly preflightChecks = signal<PreflightCheck[]>([]);
readonly preflightRunning = signal(false);
readonly newTag = signal('');
readonly draft = signal<IntegrationDraft>({
name: '',
provider: null,
type: null,
authMethod: null,
authValues: {},
scope: {},
endpoint: '',
authRefUri: '',
organizationId: '',
repositories: [],
branches: [],
namespaces: [],
tagPatterns: [],
schedule: { type: 'manual' },
webhookEnabled: false,
tags: [],
extendedConfig: {},
});
readonly preflightChecks = signal<PreflightCheck[]>([]);
readonly preflightRunning = signal(false);
readonly newTag = signal('');
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[] => {
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 canGoBack = computed(() => this.currentStepIndex() > 0);
readonly canGoNext = computed(() => {
const step = this.currentStep();
const d = this.draft();
switch (step) {
switch (this.currentStep()) {
case 'provider':
return d.provider !== null;
return this.draft().provider !== null;
case 'auth':
return this.isAuthValid();
return this.isConnectionValid();
case 'scope':
return this.isScopeValid();
case 'schedule':
@@ -111,169 +88,150 @@ export class IntegrationWizardComponent {
case 'preflight':
return this.isPreflightPassed();
case 'review':
return d.name.trim().length > 0;
return this.draft().name.trim().length > 0;
default:
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 = [
{ value: 'manual', label: 'Manual', description: 'Trigger scans manually or via API' },
{ value: 'interval', label: 'Interval', description: 'Run at fixed intervals' },
{ value: 'cron', label: 'Cron', description: 'Use cron expression for scheduling' },
];
this.draft.update((draft) => (
draft.type === integrationType
? draft
: { ...draft, type: integrationType }
));
readonly intervalOptions = [
{ value: 15, label: '15 minutes' },
{ 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()!);
if (providers.length === 1 && currentProvider === null) {
this.selectProvider(providers[0].provider);
this.currentStep.set('auth');
}
});
// Set type from input
this.draft.update(d => ({ ...d, type: this.integrationType() }));
selectProvider(provider: IntegrationProviderDefinition['provider']): void {
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(d => ({
...d,
authMethod: methodId,
authValues: {},
this.draft.update((draft) => ({
...draft,
provider,
type: this.integrationType(),
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 {
this.draft.update(d => ({
...d,
authValues: { ...d.authValues, [fieldId]: value },
updateName(value: string): void {
this.draft.update((draft) => ({ ...draft, name: 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']>(
key: K,
value: IntegrationDraft['scope'][K]
): void {
this.draft.update(d => ({
...d,
scope: { ...d.scope, [key]: value },
}));
}
parseListInput(field: 'repositories' | 'branches' | 'namespaces' | 'tagPatterns', rawValue: string): void {
const items = rawValue
.split('\n')
.map((value) => value.trim())
.filter((value) => value.length > 0);
parseScopeInput(field: keyof IntegrationDraft['scope'], rawValue: string): void {
const parsed = rawValue.split('\n').filter(v => v.trim());
this.updateScope(field, parsed as IntegrationDraft['scope'][typeof field]);
this.draft.update((draft) => ({
...draft,
[field]: items,
}));
}
updateSchedule<K extends keyof IntegrationDraft['schedule']>(
key: K,
value: IntegrationDraft['schedule'][K]
value: IntegrationDraft['schedule'][K],
): void {
this.draft.update(d => ({
...d,
schedule: { ...d.schedule, [key]: value },
this.draft.update((draft) => ({
...draft,
schedule: { ...draft.schedule, [key]: value },
}));
}
updateName(name: string): void {
this.draft.update(d => ({ ...d, name }));
}
toggleWebhook(): void {
this.draft.update(d => ({
...d,
webhookEnabled: !d.webhookEnabled,
webhookSecret: d.webhookEnabled ? undefined : this.generateWebhookSecret(),
this.draft.update((draft) => ({
...draft,
webhookEnabled: !draft.webhookEnabled,
}));
}
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 {
this.newTag.set((event.target as HTMLInputElement).value);
}
async runPreflightChecks(): Promise<void> {
this.preflightRunning.set(true);
const checks: PreflightCheck[] = this.getPreflightChecks();
this.preflightChecks.set(checks.map(c => ({ ...c, status: 'pending' })));
// 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)
);
addTag(): void {
const tag = this.newTag().trim();
if (tag.length === 0 || this.draft().tags.includes(tag)) {
return;
}
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 {
if (!this.canGoNext()) return;
const idx = this.currentStepIndex();
if (idx < this.steps.length - 1) {
const nextStep = this.steps[idx + 1];
this.currentStep.set(nextStep);
// Auto-run preflight checks when entering preflight step
if (nextStep === 'preflight' && this.preflightChecks().length === 0) {
this.runPreflightChecks();
if (!this.canGoNext()) {
return;
}
const nextIndex = this.currentStepIndex() + 1;
if (nextIndex >= this.steps.length) {
return;
}
const nextStep = this.steps[nextIndex];
this.currentStep.set(nextStep);
if (nextStep === 'preflight' && this.preflightChecks().length === 0) {
void this.runPreflightChecks();
}
}
goBack(): void {
if (!this.canGoBack()) return;
const idx = this.currentStepIndex();
if (idx > 0) {
this.currentStep.set(this.steps[idx - 1]);
if (!this.canGoBack()) {
return;
}
this.currentStep.set(this.steps[this.currentStepIndex() - 1]);
}
goToStep(step: WizardStep): void {
const targetIdx = this.steps.indexOf(step);
if (targetIdx <= this.currentStepIndex()) {
const targetIndex = this.steps.indexOf(step);
if (targetIndex <= this.currentStepIndex()) {
this.currentStep.set(step);
}
}
@@ -283,269 +241,189 @@ export class IntegrationWizardComponent {
}
onSubmit(): void {
if (this.canGoNext()) {
this.create.emit(this.draft());
const request = buildCreateIntegrationRequest(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 {
navigator.clipboard.writeText(text);
void navigator.clipboard.writeText(text);
}
private async waitForPreflightTick(): Promise<void> {
await Promise.resolve();
private ensureConfigDefaults(
existing: Record<string, string>,
definition: IntegrationProviderDefinition,
): Record<string, string> {
const defaults = { ...existing };
for (const field of definition.configFields) {
defaults[field.id] = defaults[field.id] ?? '';
}
private evaluatePreflightResult(
check: PreflightCheck
): { status: 'success' | 'warning' | 'error'; message: string } {
return defaults;
}
private isConnectionValid(): boolean {
const definition = this.selectedProvider();
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.' };
}
if (definition === null) {
return false;
}
private isAuthValid(): boolean {
const d = this.draft();
if (!d.authMethod) return false;
if (draft.endpoint.trim().length === 0 || draft.authRefUri.trim().length === 0) {
return false;
}
const method = this.selectedAuthMethod();
if (!method) return false;
return method.fields
.filter(f => f.required)
.every(f => d.authValues[f.id]?.trim().length > 0);
return definition.configFields
.filter((field) => field.required)
.every((field) => (draft.extendedConfig[field.id] ?? '').trim().length > 0);
}
private isScopeValid(): boolean {
const scope = this.draft().scope;
// At least one scope field should be defined
return !!(
(scope.repositories && scope.repositories.length > 0) ||
(scope.organizations && scope.organizations.length > 0) ||
(scope.namespaces && scope.namespaces.length > 0) ||
(scope.branches && scope.branches.length > 0)
const draft = this.draft();
return (
draft.organizationId.trim().length > 0 ||
draft.repositories.length > 0 ||
draft.branches.length > 0 ||
draft.namespaces.length > 0 ||
draft.tagPatterns.length > 0
);
}
private isScheduleValid(): boolean {
const schedule = this.draft().schedule;
switch (schedule.type) {
case 'manual': return true;
case 'interval': return (schedule.intervalMinutes ?? 0) > 0;
case 'cron': return (schedule.cronExpression ?? '').trim().length > 0;
default: return false;
case 'manual':
return true;
case 'interval':
return (schedule.intervalMinutes ?? 0) > 0;
case 'cron':
return (schedule.cronExpression ?? '').trim().length > 0;
default:
return false;
}
}
private isPreflightPassed(): boolean {
const checks = this.preflightChecks();
if (checks.length === 0) return false;
return checks.every(c => c.status === 'success' || c.status === 'warning');
return checks.length > 0 && checks.every((check) => check.status === 'success' || check.status === 'warning');
}
private getPreflightChecks(): PreflightCheck[] {
const type = this.integrationType();
const common: PreflightCheck[] = [
{ id: 'auth', name: 'Authentication', description: 'Verify credentials', status: 'pending' },
{ id: 'connectivity', name: 'Connectivity', description: 'Test network connection', status: 'pending' },
const provider = this.selectedProvider();
const checks: PreflightCheck[] = [
{ id: 'authref', name: 'Credential indirection', description: 'Verify the connector uses an AuthRef URI instead of raw secrets.', 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) {
case 'registry':
return [
...common,
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' },
{ id: 'pull-manifest', name: 'Pull Manifest', description: 'Test manifest access', status: 'pending' },
];
case 'scm':
return [
...common,
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' },
{ id: 'webhook', name: 'Webhook Setup', description: 'Verify webhook configuration', status: 'pending' },
{ id: 'permissions', name: 'Permissions', description: 'Check required permissions', status: 'pending' },
];
case 'ci':
return [
...common,
{ id: 'token-scope', name: 'Token Scope', description: 'Verify token permissions', status: 'pending' },
{ id: 'workflow-access', name: 'Workflow Access', description: 'Check workflow trigger access', status: 'pending' },
];
case 'host':
return [
{ id: 'kernel', name: 'Kernel Version', description: 'Check kernel compatibility', status: 'pending' },
{ id: 'btf', name: 'BTF Support', description: 'Verify BTF availability', status: 'pending' },
{ id: 'privileges', name: 'Privileges', description: 'Check required privileges', status: 'pending' },
{ id: 'probe-bundle', name: 'Probe Bundle', description: 'Verify probe availability', status: 'pending' },
];
if (provider?.provider === 200) {
checks.push({ id: 'github-app', name: 'GitHub App metadata', description: 'App ID and Installation ID are present as non-secret config.', status: 'pending' });
}
if (provider?.provider === 100) {
checks.push({ id: 'harbor-route', name: 'Harbor health route', description: 'Harbor endpoints must answer /api/v2.0/health.', status: 'pending' });
}
return checks;
}
private evaluatePreflightResult(
check: PreflightCheck,
): { status: 'success' | 'warning' | 'error'; message: string } {
const draft = this.draft();
const provider = this.selectedProvider();
switch (check.id) {
case 'authref':
return draft.authRefUri.trim().startsWith('authref://')
? { status: 'success', message: 'Credential indirection is configured via AuthRef URI.' }
: { status: 'error', message: 'Use an authref:// URI instead of embedding secrets in the connector.' };
case 'endpoint':
return draft.endpoint.trim().length > 0
? { status: 'success', message: `Endpoint ${draft.endpoint.trim()} will be used for connector probes.` }
: { 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:
return common;
}
}
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';
return provider
? { status: 'success', message: `${provider.name} onboarding draft is internally consistent.` }
: { status: 'error', message: 'Select a supported provider first.' };
}
}
}

View File

@@ -1,21 +1,16 @@
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
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 { IntegrationWizardComponent } from './integration-wizard.component';
import {
IntegrationType,
IntegrationDraft,
REGISTRY_PROVIDERS,
SCM_PROVIDERS,
CI_PROVIDERS,
HOST_PROVIDERS,
IntegrationOnboardingType,
resolveSupportedProviders,
} from './models/integration.models';
/**
* Integrations Hub Page (Sprint: SPRINT_20251229_014)
* Central page for managing all integrations with wizard access.
*/
@Component({
selector: 'app-integrations-hub',
standalone: true,
@@ -25,77 +20,97 @@ import {
@if (!activeWizard()) {
<header class="hub-header">
<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>
@if (loadingCatalog()) {
<section class="catalog-state" role="status">
<h2>Loading provider catalog</h2>
<p>Reading the installed connector plugins from the integrations service.</p>
</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">
<!-- Registry Integrations -->
<section class="category-section">
<div class="category-header">
<div>
<h2>Container Registries</h2>
<button class="btn btn-primary" (click)="openWizard('registry')">
<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>
<p class="category-desc">Connect container registries for automated image scanning.</p>
@if (registryProviders().length > 0) {
<div class="provider-pills">
@for (p of registryProviders; track p.id) {
<span class="provider-pill">{{ p.name }}</span>
@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">
<div class="category-header">
<div>
<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>
</div>
<button class="btn btn-primary" type="button" (click)="openWizard('scm')" [disabled]="scmProviders().length === 0">
+ Add SCM
</button>
</div>
<p class="category-desc">Connect SCM providers for repository and webhook integration.</p>
@if (scmProviders().length > 0) {
<div class="provider-pills">
@for (p of scmProviders; track p.id) {
<span class="provider-pill">{{ p.name }}</span>
@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>
}
</section>
<!-- CI/CD Integrations -->
<section class="category-section">
<div class="category-header">
<div>
<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>
</div>
<button class="btn btn-primary" type="button" (click)="openWizard('ci')" [disabled]="ciProviders().length === 0">
+ Add CI/CD
</button>
</div>
<p class="category-desc">Integrate with CI/CD platforms for pipeline-triggered scans.</p>
<div class="provider-pills">
@for (p of ciProviders; track p.id) {
<span class="provider-pill">{{ p.name }}</span>
}
</div>
<p class="category-empty">No CI/CD connector plugins are currently available.</p>
</section>
<!-- Host Integrations -->
<section class="category-section">
<div class="category-header">
<div>
<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>
</div>
<button class="btn btn-primary" type="button" (click)="openWizard('host')" [disabled]="hostProviders().length === 0">
+ Add Host
</button>
</div>
<p class="category-desc">Deploy Zastava observers for runtime signal collection.</p>
<div class="provider-pills">
@for (p of hostProviders; track p.id) {
<span class="provider-pill">{{ p.name }}</span>
}
</div>
<p class="category-empty">No runtime-host connector plugins are currently available.</p>
</section>
</div>
}
} @else {
<app-integration-wizard
[integrationType]="activeWizard()!"
[supportedProviders]="providersForType(activeWizard()!)"
[creating]="saving()"
[errorMessage]="saveError()"
(cancel)="closeWizard()"
(create)="onIntegrationCreated($event)"
/>
@@ -111,17 +126,36 @@ import {
.hub-header {
margin-bottom: 2rem;
}
h1 {
.hub-header h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
p {
.hub-header p {
margin: 0;
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 {
@@ -134,22 +168,26 @@ import {
padding: 1.5rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 1rem;
align-items: start;
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
.category-header h2 {
margin: 0 0 0.35rem;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
}
}
.category-desc {
margin: 0 0 1rem;
.category-desc,
.category-empty {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
@@ -167,7 +205,6 @@ import {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.btn {
padding: 0.5rem 1rem;
@@ -175,14 +212,21 @@ import {
font-weight: var(--font-weight-medium);
cursor: pointer;
border: none;
}
&.btn-primary {
.btn.btn-primary {
background: var(--color-brand-primary);
color: var(--color-text-heading);
&:hover {
background: var(--color-brand-primary-hover);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 720px) {
.category-header {
flex-direction: column;
}
}
`],
@@ -191,65 +235,125 @@ import {
export class IntegrationsHubComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
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 scmProviders = SCM_PROVIDERS;
readonly ciProviders = CI_PROVIDERS;
readonly hostProviders = HOST_PROVIDERS;
readonly registryProviders = computed(() => resolveSupportedProviders('registry', this.supportedCatalog()));
readonly scmProviders = computed(() => resolveSupportedProviders('scm', this.supportedCatalog()));
readonly ciProviders = computed(() => resolveSupportedProviders('ci', this.supportedCatalog()));
readonly hostProviders = computed(() => resolveSupportedProviders('host', this.supportedCatalog()));
ngOnInit(): void {
this.route.paramMap.subscribe((params) => {
const type = params.get('type');
this.activeWizard.set(this.parseWizardType(type));
});
this.loadProviderCatalog();
}
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;
}
openWizard(type: IntegrationType): void {
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 {
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 {
this.closeWizard();
void this.router.navigate(this.integrationCommands(this.getIntegrationListPath(draft.type)));
onIntegrationCreated(request: CreateIntegrationRequest): void {
this.saving.set(true);
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) {
case 'registry':
return 'registry';
return this.registryProviders();
case 'scm':
return 'scm';
return this.scmProviders();
case 'ci':
return 'ci';
return this.ciProviders();
case 'host':
return 'host';
return this.hostProviders();
default:
return [];
}
}
private parseWizardType(type: string | null): IntegrationOnboardingType | null {
switch (type) {
case 'registry':
case 'scm':
case 'ci':
case 'host':
return type;
default:
return null;
}
}
private getIntegrationListPath(type: IntegrationType | null): string {
switch (type) {
case 'scm':
return 'scm';
case 'ci':
return 'ci';
case 'host':
return 'runtime-hosts';
case 'registry':
default:
return 'registries';
}
}
private integrationCommands(...segments: string[]): string[] {
return integrationWorkspaceCommands(this.router.url, ...segments);
}

View File

@@ -1,52 +1,34 @@
/**
* Integration types and wizard models (Sprint: SPRINT_20251229_014)
*/
export type IntegrationProvider =
| 'docker-hub'
| 'harbor'
| 'ecr'
| 'acr'
| 'gcr'
| 'ghcr'
| 'github'
| 'gitlab'
| 'gitea'
| 'github-actions'
| 'gitlab-ci'
| 'gitea-actions'
| 'kubernetes'
| 'vm'
| 'baremetal';
export type IntegrationType = 'registry' | 'scm' | 'ci' | 'host';
import {
CreateIntegrationRequest,
IntegrationProvider,
IntegrationType,
SupportedProviderInfo,
} from '../../integration-hub/integration.models';
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host';
export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review';
export interface IntegrationProviderInfo {
id: IntegrationProvider;
name: string;
type: IntegrationType;
icon: string;
description: string;
docsUrl?: string;
}
export interface AuthMethod {
export interface ProviderField {
id: string;
name: string;
description: string;
fields: AuthField[];
}
export interface AuthField {
id: string;
name: string;
type: 'text' | 'password' | 'select' | 'checkbox';
label: string;
required: boolean;
placeholder?: 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 {
@@ -60,149 +42,183 @@ export interface PreflightCheck {
export interface IntegrationDraft {
name: string;
provider: IntegrationProvider | null;
type: IntegrationType | null;
authMethod: string | null;
authValues: Record<string, string>;
scope: IntegrationScope;
type: IntegrationOnboardingType | null;
endpoint: string;
authRefUri: string;
organizationId: string;
repositories: string[];
branches: string[];
namespaces: string[];
tagPatterns: string[];
schedule: IntegrationSchedule;
webhookEnabled: boolean;
webhookSecret?: string;
tags: string[];
}
export interface IntegrationScope {
repositories?: string[];
branches?: string[];
organizations?: string[];
namespaces?: string[];
tagPatterns?: string[];
environments?: string[];
extendedConfig: Record<string, string>;
}
export interface IntegrationSchedule {
type: 'manual' | 'interval' | 'cron';
intervalMinutes?: number;
cronExpression?: string;
timezone?: string;
}
export const REGISTRY_PROVIDERS: IntegrationProviderInfo[] = [
{ 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' },
{ id: 'ecr', name: 'Amazon ECR', type: 'registry', icon: 'A', description: 'AWS Elastic Container Registry' },
{ id: 'acr', name: 'Azure ACR', type: 'registry', icon: 'Z', description: 'Azure Container Registry' },
{ id: 'gcr', name: 'Google GCR', type: 'registry', icon: 'G', description: 'Google Container Registry / Artifact Registry' },
{ id: 'ghcr', name: 'GitHub GHCR', type: 'registry', icon: 'GH', description: 'GitHub Container Registry' },
];
export const SCM_PROVIDERS: IntegrationProviderInfo[] = [
{ id: 'github', name: 'GitHub', type: 'scm', icon: 'GH', description: 'GitHub repositories and organizations' },
{ id: 'gitlab', name: 'GitLab', type: 'scm', icon: 'GL', description: 'GitLab projects and groups' },
{ id: 'gitea', name: 'Gitea', type: 'scm', icon: 'GT', description: 'Self-hosted Gitea repositories' },
];
export const CI_PROVIDERS: IntegrationProviderInfo[] = [
{ id: 'github-actions', name: 'GitHub Actions', type: 'ci', icon: 'GH', description: 'GitHub Actions workflows' },
{ id: 'gitlab-ci', name: 'GitLab CI', type: 'ci', icon: 'GL', description: 'GitLab CI/CD pipelines' },
{ id: 'gitea-actions', name: 'Gitea Actions', type: 'ci', icon: 'GT', description: 'Gitea Actions workflows' },
];
export const HOST_PROVIDERS: IntegrationProviderInfo[] = [
{ id: 'kubernetes', name: 'Kubernetes', type: 'host', icon: 'K8', description: 'Kubernetes cluster with Helm/DaemonSet' },
{ id: 'vm', name: 'Virtual Machine', type: 'host', icon: 'VM', description: 'VM with systemd service' },
{ id: 'baremetal', name: 'Bare Metal', type: 'host', icon: 'BM', description: 'Bare metal server with agent' },
];
export const AUTH_METHODS: Record<IntegrationType, AuthMethod[]> = {
registry: [
const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [
{
id: 'basic',
name: 'Username & Password',
description: 'Basic authentication with registry credentials',
fields: [
{ id: 'username', name: 'Username', type: 'text', required: true, placeholder: 'Registry username' },
{ id: 'password', name: 'Password', type: 'password', required: true, placeholder: 'Registry password or token' },
],
provider: IntegrationProvider.Harbor,
type: 'registry',
name: 'Harbor',
icon: 'H',
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: [],
},
{
id: 'token',
name: 'Access Token',
description: 'Token-based authentication',
fields: [
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'Registry access token' },
],
},
{
id: 'aws-iam',
name: 'AWS IAM Role',
description: 'Authenticate using AWS IAM role',
fields: [
{ id: 'region', name: 'AWS Region', type: 'text', required: true, placeholder: 'us-east-1' },
{ id: 'roleArn', name: 'Role ARN', type: 'text', required: false, placeholder: 'arn:aws:iam::...:role/...' },
],
},
],
scm: [
{
id: 'github-app',
provider: IntegrationProvider.GitHubApp,
type: 'scm',
name: 'GitHub App',
description: 'Recommended: Install a GitHub App for fine-grained permissions',
fields: [
{ id: 'appId', name: 'App ID', type: 'text', required: true, placeholder: 'GitHub App ID' },
{ id: 'installationId', name: 'Installation ID', type: 'text', required: true, placeholder: 'Installation ID' },
{ id: 'privateKey', name: 'Private Key', type: 'password', required: true, hint: 'PEM-encoded private key' },
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',
},
],
},
{
id: 'pat',
name: 'Personal Access Token',
description: 'Use a personal access token with repo scope',
fields: [
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'ghp_..., glpat-..., or Gitea token' },
],
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: [],
},
],
ci: [
{
id: 'oidc',
name: 'OIDC Token Exchange',
description: 'Recommended: Use OIDC for keyless authentication',
fields: [
{ id: 'audience', name: 'Audience', type: 'text', required: true, placeholder: 'stellaops' },
],
},
{
id: 'token',
name: 'Service Token',
description: 'Use a service account token',
fields: [
{ id: 'token', name: 'Service Token', type: 'password', required: true, placeholder: 'Service account token' },
],
},
],
host: [
{
id: 'helm',
name: 'Helm Chart',
description: 'Deploy agent using Helm chart',
fields: [
{ id: 'namespace', name: 'Namespace', type: 'text', required: true, placeholder: 'stellaops' },
{ id: 'valuesOverride', name: 'Values Override', type: 'text', required: false, hint: 'YAML values to override' },
],
},
{
id: 'systemd',
name: 'Systemd Service',
description: 'Install agent as systemd service',
fields: [
{ 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: [],
},
],
] as const;
export const REGISTRY_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'registry');
export const SCM_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'scm');
export const CI_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'ci');
export const HOST_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'host');
export const scheduleOptions = [
{ value: 'manual', label: 'Manual', description: 'Create the connector now and trigger checks on demand.' },
{ value: 'interval', label: 'Interval', description: 'Run connector checks at a fixed interval.' },
{ value: 'cron', label: 'Cron', description: 'Use a cron expression when the platform should probe or sync.' },
] as const;
export const intervalOptions = [
{ value: 15, label: '15 minutes' },
{ 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' },
] as const;
export function resolveSupportedProviders(
type: IntegrationOnboardingType,
catalog: readonly SupportedProviderInfo[],
): IntegrationProviderDefinition[] {
const supportedProviderIds = new Set(catalog.map((item) => item.provider));
return ALL_PROVIDER_DEFINITIONS
.filter((provider) => provider.type === type && provider.exposeInUi && supportedProviderIds.has(provider.provider));
}
export function resolveProviderDefinition(provider: IntegrationProvider | null): IntegrationProviderDefinition | null {
if (provider === null) {
return null;
}
return ALL_PROVIDER_DEFINITIONS.find((item) => item.provider === provider && item.exposeInUi) ?? null;
}
export function toBackendIntegrationType(type: IntegrationOnboardingType | null): IntegrationType | null {
switch (type) {
case 'registry':
return IntegrationType.Registry;
case 'scm':
return IntegrationType.Scm;
case 'ci':
return IntegrationType.CiCd;
case 'host':
return IntegrationType.RuntimeHost;
default:
return null;
}
}
export function buildCreateIntegrationRequest(draft: IntegrationDraft): CreateIntegrationRequest | null {
const backendType = toBackendIntegrationType(draft.type);
if (backendType === null || draft.provider === null) {
return null;
}
const extendedConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(draft.extendedConfig)) {
const trimmedValue = value.trim();
if (trimmedValue.length > 0) {
extendedConfig[key] = trimmedValue;
}
}
if (draft.repositories.length > 0) {
extendedConfig['repositories'] = draft.repositories;
}
if (draft.branches.length > 0) {
extendedConfig['branches'] = draft.branches;
}
if (draft.namespaces.length > 0) {
extendedConfig['namespaces'] = draft.namespaces;
}
if (draft.tagPatterns.length > 0) {
extendedConfig['tagPatterns'] = draft.tagPatterns;
}
extendedConfig['scheduleType'] = draft.schedule.type;
if (draft.schedule.type === 'interval' && draft.schedule.intervalMinutes) {
extendedConfig['intervalMinutes'] = draft.schedule.intervalMinutes;
}
if (draft.schedule.type === 'cron' && draft.schedule.cronExpression) {
extendedConfig['cronExpression'] = draft.schedule.cronExpression;
}
if (draft.webhookEnabled) {
extendedConfig['webhookEnabled'] = true;
}
return {
name: draft.name.trim(),
description: null,
type: backendType,
provider: draft.provider,
endpoint: draft.endpoint.trim(),
authRefUri: draft.authRefUri.trim() || null,
organizationId: draft.organizationId.trim() || null,
extendedConfig: Object.keys(extendedConfig).length > 0 ? extendedConfig : null,
tags: draft.tags.length > 0 ? draft.tags : null,
};
}

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.
*
@@ -8,7 +20,82 @@
*
* 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

View File

@@ -1,30 +1,63 @@
import { Component, input, output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
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 { 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)', () => {
describe('IntegrationsHubComponent route wiring', () => {
@Component({
selector: 'app-integration-wizard',
standalone: true,
template: '',
})
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>();
}
describe('IntegrationsHubComponent onboarding flow', () => {
let fixture: ComponentFixture<IntegrationsHubComponent>;
let component: IntegrationsHubComponent;
let router: Router;
let integrationService: jasmine.SpyObj<IntegrationService>;
beforeEach(async () => {
integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['getSupportedProviders', 'create']);
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: 'registry' })),
paramMap: of(convertToParamMap({ type: null })),
},
},
],
}).compileComponents();
})
.overrideComponent(IntegrationsHubComponent, {
remove: { imports: [IntegrationWizardComponent] },
add: { imports: [IntegrationWizardStubComponent] },
})
.compileComponents();
router = TestBed.inject(Router);
fixture = TestBed.createComponent(IntegrationsHubComponent);
@@ -32,96 +65,61 @@ describe('Integration Onboarding Wizard (integration_hub)', () => {
fixture.detectChanges();
});
it('activates wizard from onboarding route param', () => {
it('shows only providers that are backed by installed plugins', () => {
expect(component.registryProviders().map((provider) => provider.name)).toEqual(['Harbor']);
expect(component.scmProviders().map((provider) => provider.name)).toEqual(['GitHub App']);
expect(component.ciProviders()).toEqual([]);
expect(component.hostProviders()).toEqual([]);
});
it('navigates to typed onboarding only when the category is supported', async () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.openWizard('registry');
component.openWizard('ci');
expect(navigateSpy).toHaveBeenCalledOnceWith(['/ops/integrations', 'onboarding', 'registry'], {
queryParamsHandling: 'merge',
});
expect(component.activeWizard()).toBe('registry');
});
it('navigates to typed onboarding route when opening wizard', () => {
it('creates the integration and routes to the created detail page', () => {
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', () => {
let fixture: ComponentFixture<IntegrationWizardComponent>;
let component: IntegrationWizardComponent;
async function createWizard(type: IntegrationType): Promise<void> {
await TestBed.configureTestingModule({
imports: [IntegrationWizardComponent],
}).compileComponents();
fixture = TestBed.createComponent(IntegrationWizardComponent);
fixture.componentRef.setInput('integrationType', type);
component = fixture.componentInstance;
fixture.detectChanges();
}
it('produces deterministic preflight check results across reruns', async () => {
await createWizard('registry');
component.selectProvider('docker-hub');
component.selectAuthMethod('token');
component.updateAuthValue('token', 'qa-token');
component.parseScopeInput('repositories', 'team/api');
await component.runPreflightChecks();
const firstRun = component.preflightChecks().map((check) => ({
id: check.id,
status: check.status,
message: check.message,
integrationService.create.and.returnValue(of({
id: 'int-42',
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();
const secondRun = component.preflightChecks().map((check) => ({
id: check.id,
status: check.status,
message: check.message,
}));
expect(secondRun).toEqual(firstRun);
component.onIntegrationCreated({
name: 'QA Harbor',
description: null,
type: IntegrationType.Registry,
provider: IntegrationProvider.Harbor,
endpoint: 'https://harbor.example.com',
authRefUri: 'authref://vault/harbor#robot',
organizationId: 'platform',
extendedConfig: null,
tags: ['qa'],
});
it('generates copy-safe Helm deployment template for host onboarding', async () => {
await createWizard('host');
component.selectProvider('kubernetes');
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');
expect(integrationService.create).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'int-42'], {
queryParamsHandling: 'merge',
});
});
});